| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra do | |
| 1 | @moduledoc """ | |
| 2 | Klepsidra keeps the contexts that define your domain | |
| 3 | and business logic. | |
| 4 | ||
| 5 | Contexts are also responsible for managing your data, regardless | |
| 6 | if it comes from the database, an external API or others. | |
| 7 | """ | |
| 8 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Accounts do | |
| 1 | @moduledoc """ | |
| 2 | The Accounts context. | |
| 3 | """ | |
| 4 | ||
| 5 | import Ecto.Query, warn: false | |
| 6 | alias Klepsidra.Repo | |
| 7 | ||
| 8 | alias Klepsidra.Accounts.User | |
| 9 | ||
| 10 | @doc """ | |
| 11 | Returns the list of users. | |
| 12 | ||
| 13 | ## Examples | |
| 14 | ||
| 15 | iex> list_users() | |
| 16 | [%User{}, ...] | |
| 17 | ||
| 18 | """ | |
| 19 | def list_users do | |
| 20 | 9 | Repo.all(User) |
| 21 | end | |
| 22 | ||
| 23 | @doc """ | |
| 24 | Gets a single user. | |
| 25 | ||
| 26 | Raises `Ecto.NoResultsError` if the User does not exist. | |
| 27 | ||
| 28 | ## Examples | |
| 29 | ||
| 30 | iex> get_user!(123) | |
| 31 | %User{} | |
| 32 | ||
| 33 | iex> get_user!(456) | |
| 34 | ** (Ecto.NoResultsError) | |
| 35 | ||
| 36 | """ | |
| 37 | 11 | def get_user!(id), do: Repo.get!(User, id) |
| 38 | ||
| 39 | @doc """ | |
| 40 | Creates a user. | |
| 41 | ||
| 42 | ## Examples | |
| 43 | ||
| 44 | iex> create_user(%{field: value}) | |
| 45 | {:ok, %User{}} | |
| 46 | ||
| 47 | iex> create_user(%{field: bad_value}) | |
| 48 | {:error, %Ecto.Changeset{}} | |
| 49 | ||
| 50 | """ | |
| 51 | def create_user(attrs \\ %{}) do | |
| 52 | %User{} | |
| 53 | |> User.changeset(attrs) | |
| 54 | 15 | |> Repo.insert() |
| 55 | end | |
| 56 | ||
| 57 | @doc """ | |
| 58 | Updates a user. | |
| 59 | ||
| 60 | ## Examples | |
| 61 | ||
| 62 | iex> update_user(user, %{field: new_value}) | |
| 63 | {:ok, %User{}} | |
| 64 | ||
| 65 | iex> update_user(user, %{field: bad_value}) | |
| 66 | {:error, %Ecto.Changeset{}} | |
| 67 | ||
| 68 | """ | |
| 69 | def update_user(%User{} = user, attrs) do | |
| 70 | user | |
| 71 | |> User.changeset(attrs) | |
| 72 | 4 | |> Repo.update() |
| 73 | end | |
| 74 | ||
| 75 | @doc """ | |
| 76 | Deletes a user. | |
| 77 | ||
| 78 | ## Examples | |
| 79 | ||
| 80 | iex> delete_user(user) | |
| 81 | {:ok, %User{}} | |
| 82 | ||
| 83 | iex> delete_user(user) | |
| 84 | {:error, %Ecto.Changeset{}} | |
| 85 | ||
| 86 | """ | |
| 87 | def delete_user(%User{} = user) do | |
| 88 | 2 | Repo.delete(user) |
| 89 | end | |
| 90 | ||
| 91 | @doc """ | |
| 92 | Returns an `%Ecto.Changeset{}` for tracking user changes. | |
| 93 | ||
| 94 | ## Examples | |
| 95 | ||
| 96 | iex> change_user(user) | |
| 97 | %Ecto.Changeset{data: %User{}} | |
| 98 | ||
| 99 | """ | |
| 100 | def change_user(%User{} = user, attrs \\ %{}) do | |
| 101 | 7 | User.changeset(user, attrs) |
| 102 | end | |
| 103 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Accounts.User do | |
| 1 | @moduledoc """ | |
| 2 | Define a schema for the `User` entity, recording authorised system users, | |
| 3 | for authorisation, ownership, auditing and logging purposes. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 10 | @foreign_key_type Ecto.UUID | |
| 11 | ||
| 12 | @type t :: %__MODULE__{ | |
| 13 | user_name: String.t(), | |
| 14 | login_email: String.t(), | |
| 15 | password_hash: String.t(), | |
| 16 | description: String.t(), | |
| 17 | frozen: boolean(), | |
| 18 | active: boolean() | |
| 19 | } | |
| 20 | 243 | schema "users" do |
| 21 | field :user_name, :string | |
| 22 | field :login_email, :string | |
| 23 | field :password_hash, :string | |
| 24 | field :description, :string | |
| 25 | field :frozen, :boolean, default: false | |
| 26 | field :active, :boolean, default: true | |
| 27 | ||
| 28 | timestamps() | |
| 29 | end | |
| 30 | ||
| 31 | @doc false | |
| 32 | def changeset(user, attrs) do | |
| 33 | user | |
| 34 | |> cast(attrs, [:user_name, :login_email, :password_hash, :frozen, :active]) | |
| 35 | |> validate_required([:user_name, :login_email, :password_hash]) | |
| 36 | |> unique_constraint(:user_name, | |
| 37 | name: :users_user_name_index, | |
| 38 | message: "This user name is taken" | |
| 39 | ) | |
| 40 | 26 | |> unique_constraint(:login_email, |
| 41 | name: :users_login_email_index, | |
| 42 | message: "This login email has already been registered" | |
| 43 | ) | |
| 44 | end | |
| 45 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Application do | |
| 1 | # See https://hexdocs.pm/elixir/Application.html | |
| 2 | # for more information on OTP Applications | |
| 3 | @moduledoc false | |
| 4 | ||
| 5 | use Application | |
| 6 | ||
| 7 | @impl true | |
| 8 | def start(_type, _args) do | |
| 9 | 1 | children = [ |
| 10 | # Start the Telemetry supervisor | |
| 11 | KlepsidraWeb.Telemetry, | |
| 12 | # Start the Ecto repository | |
| 13 | Klepsidra.Repo, | |
| 14 | # Start the PubSub system | |
| 15 | {Phoenix.PubSub, name: Klepsidra.PubSub}, | |
| 16 | # Start Finch | |
| 17 | {Finch, name: Klepsidra.Finch}, | |
| 18 | # Start the Endpoint (http/https) | |
| 19 | KlepsidraWeb.Endpoint | |
| 20 | # Start a worker by calling: Klepsidra.Worker.start_link(arg) | |
| 21 | # {Klepsidra.Worker, arg} | |
| 22 | # Place | |
| 23 | ] | |
| 24 | ||
| 25 | # See https://hexdocs.pm/elixir/Supervisor.html | |
| 26 | # for other strategies and supported options | |
| 27 | 1 | opts = [strategy: :one_for_one, name: Klepsidra.Supervisor] |
| 28 | 1 | Supervisor.start_link(children, opts) |
| 29 | end | |
| 30 | ||
| 31 | # Tell Phoenix to update the endpoint configuration | |
| 32 | # whenever the application is updated. | |
| 33 | @impl true | |
| 34 | def config_change(changed, _new, removed) do | |
| 35 | 0 | KlepsidraWeb.Endpoint.config_change(changed, removed) |
| 36 | :ok | |
| 37 | end | |
| 38 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.BusinessPartners do | |
| 1 | @moduledoc """ | |
| 2 | The BusinessPartners context. | |
| 3 | """ | |
| 4 | ||
| 5 | import Ecto.Query, warn: false | |
| 6 | alias Klepsidra.Repo | |
| 7 | ||
| 8 | alias Klepsidra.BusinessPartners.BusinessPartner | |
| 9 | ||
| 10 | @doc """ | |
| 11 | Returns the list of business_partners. | |
| 12 | ||
| 13 | ## Examples | |
| 14 | ||
| 15 | iex> list_business_partners() | |
| 16 | [%BusinessPartner{}, ...] | |
| 17 | ||
| 18 | """ | |
| 19 | def list_business_partners do | |
| 20 | 9 | BusinessPartner |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all() |
| 21 | end | |
| 22 | ||
| 23 | @doc """ | |
| 24 | Returns the list of customers | |
| 25 | ||
| 26 | ## Examples | |
| 27 | ||
| 28 | iex> list_customers() | |
| 29 | [%BusinessPartner{}, ...] | |
| 30 | ||
| 31 | """ | |
| 32 | def list_customers do | |
| 33 | BusinessPartner | |
| 34 | |> where(customer: true) | |
| 35 | 0 | |> order_by(asc: fragment("name COLLATE NOCASE")) |
| 36 | 0 | |> Repo.all() |
| 37 | end | |
| 38 | ||
| 39 | @doc """ | |
| 40 | Returns the list of 'active' business_partners. | |
| 41 | ||
| 42 | ## Examples | |
| 43 | ||
| 44 | iex> list_active_business_partners() | |
| 45 | [%BusinessPartner{}, ...] | |
| 46 | ||
| 47 | """ | |
| 48 | def list_active_business_partners do | |
| 49 | BusinessPartner | |
| 50 | |> where(active: true) | |
| 51 | 0 | |> order_by(asc: fragment("name COLLATE NOCASE")) |
| 52 | 0 | |> Repo.all() |
| 53 | end | |
| 54 | ||
| 55 | @doc """ | |
| 56 | Returns the list of customers who are active, and whose account is not | |
| 57 | frozen. | |
| 58 | ||
| 59 | ## Examples | |
| 60 | ||
| 61 | iex> list_active_customers() | |
| 62 | [%BusinessPartner{}, ...] | |
| 63 | ||
| 64 | """ | |
| 65 | def list_active_customers do | |
| 66 | BusinessPartner | |
| 67 | |> where(customer: true, frozen: false, active: true) | |
| 68 | 2 | |> order_by(asc: fragment("name COLLATE NOCASE")) |
| 69 | 2 | |> Repo.all() |
| 70 | end | |
| 71 | ||
| 72 | @doc """ | |
| 73 | Gets a single business_partner. | |
| 74 | ||
| 75 | Raises `Ecto.NoResultsError` if the Business partner does not exist. | |
| 76 | ||
| 77 | ## Examples | |
| 78 | ||
| 79 | iex> get_business_partner!(123) | |
| 80 | %BusinessPartner{} | |
| 81 | ||
| 82 | iex> get_business_partner!(456) | |
| 83 | ** (Ecto.NoResultsError) | |
| 84 | ||
| 85 | """ | |
| 86 | 11 | def get_business_partner!(id), do: Repo.get!(BusinessPartner, id) |
| 87 | ||
| 88 | @doc """ | |
| 89 | Creates a business_partner. | |
| 90 | ||
| 91 | ## Examples | |
| 92 | ||
| 93 | iex> create_business_partner(%{field: value}) | |
| 94 | {:ok, %BusinessPartner{}} | |
| 95 | ||
| 96 | iex> create_business_partner(%{field: bad_value}) | |
| 97 | {:error, %Ecto.Changeset{}} | |
| 98 | ||
| 99 | """ | |
| 100 | def create_business_partner(attrs \\ %{}) do | |
| 101 | %BusinessPartner{} | |
| 102 | |> BusinessPartner.changeset(attrs) | |
| 103 | 15 | |> Repo.insert() |
| 104 | end | |
| 105 | ||
| 106 | @doc """ | |
| 107 | Updates a business_partner. | |
| 108 | ||
| 109 | ## Examples | |
| 110 | ||
| 111 | iex> update_business_partner(business_partner, %{field: new_value}) | |
| 112 | {:ok, %BusinessPartner{}} | |
| 113 | ||
| 114 | iex> update_business_partner(business_partner, %{field: bad_value}) | |
| 115 | {:error, %Ecto.Changeset{}} | |
| 116 | ||
| 117 | """ | |
| 118 | def update_business_partner(%BusinessPartner{} = business_partner, attrs) do | |
| 119 | business_partner | |
| 120 | |> BusinessPartner.changeset(attrs) | |
| 121 | 4 | |> Repo.update() |
| 122 | end | |
| 123 | ||
| 124 | @doc """ | |
| 125 | Deletes a business_partner. | |
| 126 | ||
| 127 | ## Examples | |
| 128 | ||
| 129 | iex> delete_business_partner(business_partner) | |
| 130 | {:ok, %BusinessPartner{}} | |
| 131 | ||
| 132 | iex> delete_business_partner(business_partner) | |
| 133 | {:error, %Ecto.Changeset{}} | |
| 134 | ||
| 135 | """ | |
| 136 | def delete_business_partner(%BusinessPartner{} = business_partner) do | |
| 137 | 2 | Repo.delete(business_partner) |
| 138 | end | |
| 139 | ||
| 140 | @doc """ | |
| 141 | Returns an `%Ecto.Changeset{}` for tracking business_partner changes. | |
| 142 | ||
| 143 | ## Examples | |
| 144 | ||
| 145 | iex> change_business_partner(business_partner) | |
| 146 | %Ecto.Changeset{data: %BusinessPartner{}} | |
| 147 | ||
| 148 | """ | |
| 149 | def change_business_partner(%BusinessPartner{} = business_partner, attrs \\ %{}) do | |
| 150 | 7 | BusinessPartner.changeset(business_partner, attrs) |
| 151 | end | |
| 152 | ||
| 153 | alias Klepsidra.BusinessPartners.Note | |
| 154 | ||
| 155 | @doc """ | |
| 156 | Returns the list of business_partner_notes. | |
| 157 | ||
| 158 | ## Examples | |
| 159 | ||
| 160 | iex> list_business_partner_notes() | |
| 161 | [%Note{}, ...] | |
| 162 | ||
| 163 | """ | |
| 164 | def list_business_partner_notes do | |
| 165 | 0 | Repo.all(Note) |
| 166 | end | |
| 167 | ||
| 168 | @doc """ | |
| 169 | Gets a single note. | |
| 170 | ||
| 171 | Raises `Ecto.NoResultsError` if the Note does not exist. | |
| 172 | ||
| 173 | ## Examples | |
| 174 | ||
| 175 | iex> get_note!(123) | |
| 176 | %Note{} | |
| 177 | ||
| 178 | iex> get_note!(456) | |
| 179 | ** (Ecto.NoResultsError) | |
| 180 | ||
| 181 | """ | |
| 182 | 0 | def get_note!(id), do: Repo.get!(Note, id) |
| 183 | ||
| 184 | @doc """ | |
| 185 | Creates a note. | |
| 186 | ||
| 187 | ## Examples | |
| 188 | ||
| 189 | iex> create_note(%{field: value}) | |
| 190 | {:ok, %Note{}} | |
| 191 | ||
| 192 | iex> create_note(%{field: bad_value}) | |
| 193 | {:error, %Ecto.Changeset{}} | |
| 194 | ||
| 195 | """ | |
| 196 | def create_note(attrs \\ %{}) do | |
| 197 | %Note{} | |
| 198 | |> Note.changeset(attrs) | |
| 199 | 0 | |> Repo.insert() |
| 200 | end | |
| 201 | ||
| 202 | @doc """ | |
| 203 | Updates a note. | |
| 204 | ||
| 205 | ## Examples | |
| 206 | ||
| 207 | iex> update_note(note, %{field: new_value}) | |
| 208 | {:ok, %Note{}} | |
| 209 | ||
| 210 | iex> update_note(note, %{field: bad_value}) | |
| 211 | {:error, %Ecto.Changeset{}} | |
| 212 | ||
| 213 | """ | |
| 214 | def update_note(%Note{} = note, attrs) do | |
| 215 | note | |
| 216 | |> Note.changeset(attrs) | |
| 217 | 0 | |> Repo.update() |
| 218 | end | |
| 219 | ||
| 220 | @doc """ | |
| 221 | Deletes a note. | |
| 222 | ||
| 223 | ## Examples | |
| 224 | ||
| 225 | iex> delete_note(note) | |
| 226 | {:ok, %Note{}} | |
| 227 | ||
| 228 | iex> delete_note(note) | |
| 229 | {:error, %Ecto.Changeset{}} | |
| 230 | ||
| 231 | """ | |
| 232 | def delete_note(%Note{} = note) do | |
| 233 | 0 | Repo.delete(note) |
| 234 | end | |
| 235 | ||
| 236 | @doc """ | |
| 237 | Returns an `%Ecto.Changeset{}` for tracking note changes. | |
| 238 | ||
| 239 | ## Examples | |
| 240 | ||
| 241 | iex> change_note(note) | |
| 242 | %Ecto.Changeset{data: %Note{}} | |
| 243 | ||
| 244 | """ | |
| 245 | def change_note(%Note{} = note, attrs \\ %{}) do | |
| 246 | 0 | Note.changeset(note, attrs) |
| 247 | end | |
| 248 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.BusinessPartners.BusinessPartner do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `Business Partners` entity, recording customers and | |
| 3 | suppliers of the busines. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 10 | @foreign_key_type Ecto.UUID | |
| 11 | ||
| 12 | @type t :: %__MODULE__{ | |
| 13 | name: String.t(), | |
| 14 | description: String.t(), | |
| 15 | default_currency: String.t(), | |
| 16 | customer: boolean(), | |
| 17 | supplier: boolean(), | |
| 18 | frozen: boolean(), | |
| 19 | active: boolean() | |
| 20 | } | |
| 21 | 281 | schema "business_partners" do |
| 22 | field :name, :string | |
| 23 | field :description, :string | |
| 24 | field :default_currency, :string | |
| 25 | field :customer, :boolean, default: false | |
| 26 | field :supplier, :boolean, default: false | |
| 27 | field :frozen, :boolean, default: false | |
| 28 | field :active, :boolean, default: true | |
| 29 | ||
| 30 | timestamps() | |
| 31 | end | |
| 32 | ||
| 33 | @doc false | |
| 34 | def changeset(business_partner, attrs) do | |
| 35 | business_partner | |
| 36 | |> cast(attrs, [:name, :description, :customer, :supplier, :active]) | |
| 37 | |> validate_required([:name], message: "Enter a customer name") | |
| 38 | 26 | |> unique_constraint(:name, |
| 39 | name: :business_partners_name_index, | |
| 40 | message: "A customer with this name already exists" | |
| 41 | ) | |
| 42 | end | |
| 43 | ||
| 44 | @doc """ | |
| 45 | Used across live components to populate select options with projects. | |
| 46 | """ | |
| 47 | @spec populate_customers_list() :: [Klepsidra.BusinessPartners.BusinessPartner.t(), ...] | |
| 48 | 2 | def populate_customers_list() do |
| 49 | [ | |
| 50 | {"", ""} | |
| 51 | | Klepsidra.BusinessPartners.list_active_customers() | |
| 52 | 0 | |> Enum.map(fn bp -> {bp.name, bp.id} end) |
| 53 | ] | |
| 54 | end | |
| 55 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.BusinessPartners.Note do | |
| 1 | @moduledoc """ | |
| 2 | Defines the data schema for the business partners`Note` entity, | |
| 3 | annotations of business partners. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 10 | @foreign_key_type Ecto.UUID | |
| 11 | ||
| 12 | @type t :: %__MODULE__{ | |
| 13 | note: String.t(), | |
| 14 | business_partner_id: binary() | |
| 15 | } | |
| 16 | 0 | schema "business_partner_notes" do |
| 17 | field :note, :string | |
| 18 | belongs_to :business_partner, BusinessPartner, type: Ecto.UUID | |
| 19 | ||
| 20 | timestamps() | |
| 21 | end | |
| 22 | ||
| 23 | @doc false | |
| 24 | def changeset(note, attrs) do | |
| 25 | note | |
| 26 | |> cast(attrs, [:note, :business_partner_id]) | |
| 27 | |> validate_required([:note], message: "The message can't be empty") | |
| 28 | 0 | |> assoc_constraint(:business_partner) |
| 29 | end | |
| 30 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Categorisation do | |
| 1 | @moduledoc """ | |
| 2 | The Categorisation context provides a way to categorise entities within the | |
| 3 | application. | |
| 4 | ||
| 5 | A general-purpose _tagging_ module provides a record of all tags used for various entities. | |
| 6 | Presently, tagging is only used in activity timers so that users can simply categorise | |
| 7 | their timed activities, to help filter activities by category, to search for timers, and to | |
| 8 | make it easier to collate timers with client invoicing in mind. | |
| 9 | ||
| 10 | Tagging activity timers requires a many-to-many relationship between timers and | |
| 11 | tags, which is recorded in the `timer_tags` table. | |
| 12 | """ | |
| 13 | ||
| 14 | import Ecto.Query, warn: false | |
| 15 | alias Klepsidra.Repo | |
| 16 | ||
| 17 | alias Klepsidra.Categorisation.Tag | |
| 18 | alias Klepsidra.Categorisation.TimerTags | |
| 19 | alias Klepsidra.Categorisation.ProjectTags | |
| 20 | alias Klepsidra.Categorisation.JournalEntryTags | |
| 21 | ||
| 22 | @doc """ | |
| 23 | Returns the list of tags. | |
| 24 | ||
| 25 | ## Examples | |
| 26 | ||
| 27 | iex> list_tags() | |
| 28 | [%Tag{}, ...] | |
| 29 | ||
| 30 | """ | |
| 31 | def list_tags do | |
| 32 | 9 | Tag |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all() |
| 33 | end | |
| 34 | ||
| 35 | @doc """ | |
| 36 | Gets a single tag. | |
| 37 | ||
| 38 | Raises `Ecto.NoResultsError` if the Tag does not exist. | |
| 39 | ||
| 40 | ## Examples | |
| 41 | ||
| 42 | iex> get_tag!(123) | |
| 43 | %Tag{} | |
| 44 | ||
| 45 | iex> get_tag!(456) | |
| 46 | ** (Ecto.NoResultsError) | |
| 47 | ||
| 48 | """ | |
| 49 | 11 | def get_tag!(id), do: Repo.get!(Tag, id) |
| 50 | ||
| 51 | @doc """ | |
| 52 | Gets multiple tags. | |
| 53 | ||
| 54 | Raises `Ecto.NoResultsError` if the Tag id is not a proper UUID. | |
| 55 | ||
| 56 | ## Examples | |
| 57 | ||
| 58 | iex> get_tags!([123, 789]) | |
| 59 | %Tag{} | |
| 60 | ||
| 61 | iex> get_tag!([]) | |
| 62 | [] | |
| 63 | ||
| 64 | iex> get_tag!([""]) | |
| 65 | ** (Ecto.Query.CastError) | |
| 66 | ||
| 67 | """ | |
| 68 | @spec get_tags!(id_list :: [Ecto.UUID.t(), ...]) :: [Tag.t(), ...] | [] | |
| 69 | def get_tags!(id_list) when is_list(id_list) do | |
| 70 | 0 | Repo.all(from(t in Tag, where: t.id in ^id_list)) |
| 71 | end | |
| 72 | ||
| 73 | @doc """ | |
| 74 | Gets multiple tags, sorted by tag name. | |
| 75 | ||
| 76 | Raises `Ecto.NoResultsError` if the Tag id is not a proper UUID. | |
| 77 | ||
| 78 | ## Examples | |
| 79 | ||
| 80 | iex> get_tags_sorted!([123, 789]) | |
| 81 | %Tag{} | |
| 82 | ||
| 83 | iex> get_tag_sorted!([]) | |
| 84 | [] | |
| 85 | ||
| 86 | iex> get_tag_sorted!([""]) | |
| 87 | ** (Ecto.Query.CastError) | |
| 88 | ||
| 89 | """ | |
| 90 | @spec get_tags_sorted!(id_list :: [Ecto.UUID.t(), ...]) :: [Tag.t(), ...] | [] | |
| 91 | def get_tags_sorted!(id_list) when is_list(id_list) do | |
| 92 | 0 | Repo.all( |
| 93 | from( | |
| 94 | t in Tag, | |
| 95 | where: t.id in ^id_list, | |
| 96 | order_by: [asc: t.name] | |
| 97 | ) | |
| 98 | ) | |
| 99 | end | |
| 100 | ||
| 101 | @doc """ | |
| 102 | Simple search for tags defined in the system, performing a prefix filter only. | |
| 103 | ||
| 104 | This search takes in the `search_phrase`, and after converting it to lowercase, | |
| 105 | compares it against a list of similarly lowercased tag names (`name` field), from the | |
| 106 | database. The comparison checks filters all tags that start with the normalised | |
| 107 | search phrase. | |
| 108 | ||
| 109 | ## Examples | |
| 110 | ||
| 111 | iex> search_tags_by_name_prefix("hello") | |
| 112 | [%Tag{}, ...] | |
| 113 | """ | |
| 114 | @spec search_tags_by_name_prefix(String.t()) :: [Tag.t(), ...] | |
| 115 | def search_tags_by_name_prefix(search_phrase) do | |
| 116 | 0 | search_phrase = String.downcase(search_phrase) |
| 117 | ||
| 118 | Klepsidra.Categorisation.list_tags() | |
| 119 | 0 | |> Enum.filter(fn %{name: name} -> |
| 120 | 0 | String.starts_with?(String.downcase(name), search_phrase) |
| 121 | end) | |
| 122 | end | |
| 123 | ||
| 124 | @doc """ | |
| 125 | Search for tags using a `LIKE` wildcard search. | |
| 126 | """ | |
| 127 | @spec search_tags_by_name_content(search_phrase :: String.t()) :: [Tag.t(), ...] | |
| 128 | def search_tags_by_name_content(search_phrase) when is_bitstring(search_phrase) do | |
| 129 | 0 | search_fragment = "%#{String.downcase(search_phrase)}%" |
| 130 | ||
| 131 | 0 | query = |
| 132 | from(t in Tag, | |
| 133 | where: like(t.name, ^search_fragment), | |
| 134 | order_by: [asc: fragment("lower(?)", t.name)] | |
| 135 | ) | |
| 136 | ||
| 137 | 0 | Repo.all(query) |
| 138 | end | |
| 139 | ||
| 140 | @doc """ | |
| 141 | Creates a tag. | |
| 142 | ||
| 143 | ## Examples | |
| 144 | ||
| 145 | iex> create_tag(%{field: value}) | |
| 146 | {:ok, %Tag{}} | |
| 147 | ||
| 148 | iex> create_tag(%{field: bad_value}) | |
| 149 | {:error, %Ecto.Changeset{}} | |
| 150 | ||
| 151 | """ | |
| 152 | @spec create_tag(attrs :: map()) :: {:ok, Tag.t()} | {:error, any()} | |
| 153 | def create_tag(attrs \\ %{}) do | |
| 154 | %Tag{} | |
| 155 | |> Tag.changeset(attrs) | |
| 156 | 15 | |> Repo.insert() |
| 157 | end | |
| 158 | ||
| 159 | @doc """ | |
| 160 | Creates a tag if it doesn't exist, otherwise gets and returns a tag | |
| 161 | matching the name provided. | |
| 162 | ||
| 163 | ## Examples | |
| 164 | ||
| 165 | iex> create_or_find_tag(%{name: good_name}) | |
| 166 | %Tag{} | |
| 167 | ||
| 168 | iex> create_or_find_tag(%{field: existing_name}) | |
| 169 | %Tag{} | |
| 170 | ||
| 171 | """ | |
| 172 | @spec create_or_find_tag(attrs :: map()) :: Tag.t() | |
| 173 | def create_or_find_tag(%{name: "" <> name} = attrs) do | |
| 174 | %Tag{} | |
| 175 | |> Tag.changeset(attrs) | |
| 176 | |> Repo.insert() | |
| 177 | 0 | |> case do |
| 178 | {:ok, tag} -> | |
| 179 | 0 | tag |
| 180 | ||
| 181 | {:error, _changeset} -> | |
| 182 | 0 | Repo.get_by(Tag, name: name) |
| 183 | ||
| 184 | _ -> | |
| 185 | 0 | Repo.get_by(Tag, name: name) |
| 186 | end | |
| 187 | end | |
| 188 | ||
| 189 | 0 | def create_or_find_tag(_), do: nil |
| 190 | ||
| 191 | @doc """ | |
| 192 | Updates a tag. | |
| 193 | ||
| 194 | ## Examples | |
| 195 | ||
| 196 | iex> update_tag(tag, %{field: new_value}) | |
| 197 | {:ok, %Tag{}} | |
| 198 | ||
| 199 | iex> update_tag(tag, %{field: bad_value}) | |
| 200 | {:error, %Ecto.Changeset{}} | |
| 201 | ||
| 202 | """ | |
| 203 | def update_tag(%Tag{} = tag, attrs) do | |
| 204 | tag | |
| 205 | |> Tag.changeset(attrs) | |
| 206 | 4 | |> Repo.update() |
| 207 | end | |
| 208 | ||
| 209 | @doc """ | |
| 210 | Deletes a tag. | |
| 211 | ||
| 212 | ## Examples | |
| 213 | ||
| 214 | iex> delete_tag(tag) | |
| 215 | {:ok, %Tag{}} | |
| 216 | ||
| 217 | iex> delete_tag(tag) | |
| 218 | {:error, %Ecto.Changeset{}} | |
| 219 | ||
| 220 | """ | |
| 221 | def delete_tag(%Tag{} = tag) do | |
| 222 | 2 | Repo.delete(tag) |
| 223 | end | |
| 224 | ||
| 225 | @doc """ | |
| 226 | Returns an `%Ecto.Changeset{}` for tracking tag changes. | |
| 227 | ||
| 228 | ## Examples | |
| 229 | ||
| 230 | iex> change_tag(tag) | |
| 231 | %Ecto.Changeset{data: %Tag{}} | |
| 232 | ||
| 233 | """ | |
| 234 | def change_tag(%Tag{} = tag, attrs \\ %{}) do | |
| 235 | 7 | Tag.changeset(tag, attrs) |
| 236 | end | |
| 237 | ||
| 238 | @doc """ | |
| 239 | Attach a single tag to a timer. Checks if the tag is already associated | |
| 240 | with the timer, only adding it if it’s missing. | |
| 241 | ||
| 242 | ## Examples | |
| 243 | ||
| 244 | iex> add_timer_tag(%Timer{}, %Tag{}) | |
| 245 | {:ok, :inserted} | |
| 246 | ||
| 247 | iex> add_timer_tag(%Timer{}, %Tag{}) | |
| 248 | {:ok, :already_exists} | |
| 249 | ||
| 250 | iex> add_timer_tag(%Timer{}, %Tag{}) | |
| 251 | {:error, :insert_failed} | |
| 252 | ||
| 253 | """ | |
| 254 | @spec add_timer_tag(timer_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: | |
| 255 | {:ok, :inserted} | |
| 256 | | {:ok, :already_exists} | |
| 257 | | {:error, :insert_failed} | |
| 258 | | {:error, :timer_is_nil} | |
| 259 | 0 | def add_timer_tag(nil, _tag_id), do: {:error, :timer_is_nil} |
| 260 | ||
| 261 | def add_timer_tag(timer_id, tag_id) do | |
| 262 | 0 | now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) |
| 263 | ||
| 264 | # Check if the tag is already associated with the timer | |
| 265 | 0 | existing_association = |
| 266 | Repo.get_by(TimerTags, timer_id: timer_id, tag_id: tag_id) | |
| 267 | ||
| 268 | # Repo.update(changeset) | |
| 269 | 0 | if existing_association do |
| 270 | {:ok, :already_exists} | |
| 271 | else | |
| 272 | # Insert a new association with timestamps | |
| 273 | 0 | timer_tag_entry = %TimerTags{ |
| 274 | timer_id: timer_id, | |
| 275 | tag_id: tag_id, | |
| 276 | inserted_at: now, | |
| 277 | updated_at: now | |
| 278 | } | |
| 279 | ||
| 280 | 0 | case Repo.insert(timer_tag_entry) do |
| 281 | 0 | {:ok, _} -> {:ok, :inserted} |
| 282 | 0 | {:error, _} -> {:error, :insert_failed} |
| 283 | end | |
| 284 | end | |
| 285 | end | |
| 286 | ||
| 287 | @doc """ | |
| 288 | Deletes a timer's tag association. | |
| 289 | ||
| 290 | ## Examples | |
| 291 | ||
| 292 | iex> delete_timer_tag(%Timer(), %Tag()) | |
| 293 | {:ok, :deleted} | |
| 294 | ||
| 295 | iex> delete_timer_tag(%Timer(), %Tag()) | |
| 296 | {:error, :not_found} | |
| 297 | ||
| 298 | iex> delete_timer_tag(%Timer(), %Tag()) | |
| 299 | {:error, :delete_failed} | |
| 300 | ||
| 301 | """ | |
| 302 | @spec delete_timer_tag(timer_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: | |
| 303 | {:ok, :deleted} | {:error, :not_found} | {:error, :delete_failed} | |
| 304 | def delete_timer_tag(timer_id, tag_id) do | |
| 305 | # Execute the delete operation on the "timer_tags" table | |
| 306 | 0 | case Repo.get_by(TimerTags, timer_id: timer_id, tag_id: tag_id) do |
| 307 | # Record not found | |
| 308 | 0 | nil -> |
| 309 | {:error, :not_found} | |
| 310 | ||
| 311 | timer_tag -> | |
| 312 | 0 | case Repo.delete(timer_tag) do |
| 313 | 0 | {:ok, _} -> {:ok, :deleted} |
| 314 | 0 | {:error, _} -> {:error, :delete_failed} |
| 315 | end | |
| 316 | end | |
| 317 | end | |
| 318 | ||
| 319 | @doc """ | |
| 320 | Gets a single timer tag record. | |
| 321 | ||
| 322 | Raises `Ecto.NoResultsError` if the Tag does not exist. | |
| 323 | ||
| 324 | ## Examples | |
| 325 | ||
| 326 | iex> get_timer_tag!("timer_id", "tag_id") | |
| 327 | %TimerTags{} | |
| 328 | ||
| 329 | iex> get_timer_tag!("", "") | |
| 330 | ** (Ecto.NoResultsError) | |
| 331 | ||
| 332 | """ | |
| 333 | @spec get_timer_tag!(timer_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: TimerTags.t() | |
| 334 | def get_timer_tag!(timer_id, tag_id), | |
| 335 | 0 | do: Repo.get_by!(TimerTags, timer_id: timer_id, tag_id: tag_id) |
| 336 | ||
| 337 | @doc """ | |
| 338 | Attach a single tag to a project. Checks if the tag is already associated | |
| 339 | with the project, only adding it if it’s missing. | |
| 340 | ||
| 341 | ## Examples | |
| 342 | ||
| 343 | iex> add_project_tag(%Project{}, %Tag{}) | |
| 344 | {:ok, :inserted} | |
| 345 | ||
| 346 | iex> add_project_tag(%Project{}, %Tag{}) | |
| 347 | {:ok, :already_exists} | |
| 348 | ||
| 349 | iex> add_project_tag(%Project{}, %Tag{}) | |
| 350 | {:error, :insert_failed} | |
| 351 | ||
| 352 | """ | |
| 353 | @spec add_project_tag(project_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: | |
| 354 | {:ok, :inserted} | |
| 355 | | {:ok, :already_exists} | |
| 356 | | {:error, :insert_failed} | |
| 357 | | {:error, :project_is_nil} | |
| 358 | 0 | def add_project_tag(nil, _tag_id), do: {:error, :project_is_nil} |
| 359 | ||
| 360 | def add_project_tag(project_id, tag_id) do | |
| 361 | 0 | now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) |
| 362 | ||
| 363 | # Check if the tag is already associated with the project | |
| 364 | 0 | existing_association = |
| 365 | Repo.get_by(ProjectTags, project_id: project_id, tag_id: tag_id) | |
| 366 | ||
| 367 | # Repo.update(changeset) | |
| 368 | 0 | if existing_association do |
| 369 | {:ok, :already_exists} | |
| 370 | else | |
| 371 | # Insert a new association with timestamps | |
| 372 | 0 | project_tag_entry = %ProjectTags{ |
| 373 | project_id: project_id, | |
| 374 | tag_id: tag_id, | |
| 375 | inserted_at: now, | |
| 376 | updated_at: now | |
| 377 | } | |
| 378 | ||
| 379 | 0 | case Repo.insert(project_tag_entry) do |
| 380 | 0 | {:ok, _} -> {:ok, :inserted} |
| 381 | 0 | {:error, _} -> {:error, :insert_failed} |
| 382 | end | |
| 383 | end | |
| 384 | end | |
| 385 | ||
| 386 | @doc """ | |
| 387 | Deletes a project's tag association. | |
| 388 | ||
| 389 | ## Examples | |
| 390 | ||
| 391 | iex> delete_project_tag(%Project(), %Tag()) | |
| 392 | {:ok, :deleted} | |
| 393 | ||
| 394 | iex> delete_project_tag(%Project(), %Tag()) | |
| 395 | {:error, :not_found} | |
| 396 | ||
| 397 | iex> delete_project_tag(%Project(), %Tag()) | |
| 398 | {:error, :unexpected_result} | |
| 399 | ||
| 400 | """ | |
| 401 | @spec delete_project_tag(project_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: | |
| 402 | {:ok, :deleted} | {:error, :not_found} | {:error, :delete_failed} | |
| 403 | def delete_project_tag(project_id, tag_id) do | |
| 404 | # Execute the delete operation on the "project_tags" table | |
| 405 | 0 | case Repo.get_by(ProjectTags, project_id: project_id, tag_id: tag_id) do |
| 406 | # Record not found | |
| 407 | 0 | nil -> |
| 408 | {:error, :not_found} | |
| 409 | ||
| 410 | project_tag -> | |
| 411 | 0 | case Repo.delete(project_tag) do |
| 412 | 0 | {:ok, _} -> {:ok, :deleted} |
| 413 | 0 | {:error, _} -> {:error, :delete_failed} |
| 414 | end | |
| 415 | end | |
| 416 | end | |
| 417 | ||
| 418 | @doc """ | |
| 419 | Gets a single project tag. | |
| 420 | ||
| 421 | Raises `Ecto.NoResultsError` if the Project tag does not exist. | |
| 422 | ||
| 423 | ## Examples | |
| 424 | ||
| 425 | iex> get_project_tag!(123) | |
| 426 | %ProjectTags{} | |
| 427 | ||
| 428 | iex> get_project_tag!(456) | |
| 429 | ** (Ecto.NoResultsError) | |
| 430 | ||
| 431 | """ | |
| 432 | @spec get_project_tag!(project_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: ProjectTags.t() | |
| 433 | def get_project_tag!(project_id, tag_id), | |
| 434 | 0 | do: Repo.get!(ProjectTags, project_id: project_id, tag_id: tag_id) |
| 435 | ||
| 436 | @doc """ | |
| 437 | Attach a single tag to a journal entry. Checks if the tag is already associated | |
| 438 | with the entry, only adding it if it’s missing. | |
| 439 | ||
| 440 | ## Examples | |
| 441 | ||
| 442 | iex> add_journal_entry_tag(%JournalEntry{}, %Tag{}) | |
| 443 | {:ok, :inserted} | |
| 444 | ||
| 445 | iex> add_journal_entry_tag(%JournalEntry{}, %Tag{}) | |
| 446 | {:ok, :already_exists} | |
| 447 | ||
| 448 | iex> add_journal_entry_tag(%JournalEntry{}, %Tag{}) | |
| 449 | {:error, :insert_failed} | |
| 450 | ||
| 451 | """ | |
| 452 | @spec add_journal_entry_tag(journal_entry_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: | |
| 453 | {:ok, :inserted} | |
| 454 | | {:ok, :already_exists} | |
| 455 | | {:error, :insert_failed} | |
| 456 | | {:error, :journal_entry_is_nil} | |
| 457 | 0 | def add_journal_entry_tag(nil, _tag_id), do: {:error, :journal_entry_is_nil} |
| 458 | ||
| 459 | def add_journal_entry_tag(journal_entry_id, tag_id) do | |
| 460 | 0 | now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) |
| 461 | ||
| 462 | # Check if the tag is already associated with the journal entry | |
| 463 | 0 | existing_association = |
| 464 | Repo.get_by(JournalEntryTags, journal_entry_id: journal_entry_id, tag_id: tag_id) | |
| 465 | ||
| 466 | # Repo.update(changeset) | |
| 467 | 0 | if existing_association do |
| 468 | {:ok, :already_exists} | |
| 469 | else | |
| 470 | # Insert a new association with timestamps | |
| 471 | 0 | journal_entry_tag_entry = %JournalEntryTags{ |
| 472 | journal_entry_id: journal_entry_id, | |
| 473 | tag_id: tag_id, | |
| 474 | inserted_at: now, | |
| 475 | updated_at: now | |
| 476 | } | |
| 477 | ||
| 478 | 0 | case Repo.insert(journal_entry_tag_entry) do |
| 479 | 0 | {:ok, _} -> {:ok, :inserted} |
| 480 | 0 | {:error, _} -> {:error, :insert_failed} |
| 481 | end | |
| 482 | end | |
| 483 | end | |
| 484 | ||
| 485 | @doc """ | |
| 486 | Deletes a journal entry's tag association. | |
| 487 | ||
| 488 | ## Examples | |
| 489 | ||
| 490 | iex> delete_journal_entry_tag(%JournalEntry(), %Tag()) | |
| 491 | {:ok, :deleted} | |
| 492 | ||
| 493 | iex> delete_journal_entry_tag(%JournalEntry(), %Tag()) | |
| 494 | {:error, :not_found} | |
| 495 | ||
| 496 | iex> delete_journal_entry_tag(%JournalEntry(), %Tag()) | |
| 497 | {:error, :unexpected_result} | |
| 498 | ||
| 499 | """ | |
| 500 | @spec delete_journal_entry_tag(journal_entry_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: | |
| 501 | {:ok, :deleted} | {:error, :not_found} | {:error, :delete_failed} | |
| 502 | def delete_journal_entry_tag(journal_entry_id, tag_id) do | |
| 503 | # Execute the delete operation on the "journal_entry_tags" table | |
| 504 | 0 | case Repo.get_by(JournalEntryTags, journal_entry_id: journal_entry_id, tag_id: tag_id) do |
| 505 | # Record not found | |
| 506 | 0 | nil -> |
| 507 | {:error, :not_found} | |
| 508 | ||
| 509 | journal_entry_tag -> | |
| 510 | 0 | case Repo.delete(journal_entry_tag) do |
| 511 | 0 | {:ok, _} -> {:ok, :deleted} |
| 512 | 0 | {:error, _} -> {:error, :delete_failed} |
| 513 | end | |
| 514 | end | |
| 515 | end | |
| 516 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Categorisation.JournalEntryTags do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `JournalEntryTags` entity, used to create a many-to-many | |
| 3 | relationship between journal entries and tags. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | alias Klepsidra.Journals.JournalEntry | |
| 10 | alias Klepsidra.Categorisation.Tag | |
| 11 | ||
| 12 | @primary_key false | |
| 13 | @foreign_key_type Ecto.UUID | |
| 14 | ||
| 15 | @type t :: %__MODULE__{ | |
| 16 | journal_entry_id: Ecto.UUID.t(), | |
| 17 | tag_id: Ecto.UUID.t() | |
| 18 | } | |
| 19 | 0 | schema "journal_entry_tags" do |
| 20 | belongs_to(:journal_entry, JournalEntry, primary_key: true, type: Ecto.UUID) | |
| 21 | belongs_to(:tag, Tag, primary_key: true, type: Ecto.UUID) | |
| 22 | ||
| 23 | timestamps() | |
| 24 | end | |
| 25 | ||
| 26 | @doc false | |
| 27 | def changeset(journal_entry_tags, _attrs) do | |
| 28 | journal_entry_tags | |
| 29 | |> unique_constraint([:journal_entry, :tag], | |
| 30 | name: "journal_entry_tags_journal_entry_id_tag_id_index", | |
| 31 | message: "This tag has already been added to the journal entry" | |
| 32 | ) | |
| 33 | 0 | |> cast_assoc(:tag) |
| 34 | end | |
| 35 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Categorisation.ProjectTags do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `ProjectTags` entity, used to create a many-to-many | |
| 3 | relationship between projects and tags. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | alias Klepsidra.Projects.Project | |
| 10 | alias Klepsidra.Categorisation.Tag | |
| 11 | ||
| 12 | @primary_key false | |
| 13 | @foreign_key_type Ecto.UUID | |
| 14 | ||
| 15 | @type t :: %__MODULE__{ | |
| 16 | project_id: Ecto.UUID.t(), | |
| 17 | tag_id: Ecto.UUID.t() | |
| 18 | } | |
| 19 | 0 | schema "project_tags" do |
| 20 | belongs_to(:project, Project, primary_key: true, type: Ecto.UUID) | |
| 21 | belongs_to(:tag, Tag, primary_key: true, type: Ecto.UUID) | |
| 22 | ||
| 23 | timestamps() | |
| 24 | end | |
| 25 | ||
| 26 | @doc false | |
| 27 | def changeset(project_tags, _attrs) do | |
| 28 | project_tags | |
| 29 | |> unique_constraint([:project, :tag], | |
| 30 | name: "project_tags_project_id_tag_id_index", | |
| 31 | message: "This tag has already been added to the project" | |
| 32 | ) | |
| 33 | 0 | |> cast_assoc(:tag) |
| 34 | end | |
| 35 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Categorisation.Tag do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `Tags` entity, used for categorising timed activities | |
| 3 | with free form tags. | |
| 4 | ||
| 5 | To provide a helpful flourish which will make selected tags stand out, we include a | |
| 6 | `colour` field. | |
| 7 | """ | |
| 8 | ||
| 9 | use Ecto.Schema | |
| 10 | import Ecto.Changeset | |
| 11 | ||
| 12 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 13 | @foreign_key_type Ecto.UUID | |
| 14 | ||
| 15 | @type t :: %__MODULE__{ | |
| 16 | name: String.t(), | |
| 17 | description: String.t(), | |
| 18 | colour: String.t(), | |
| 19 | fg_colour: String.t() | |
| 20 | } | |
| 21 | 314 | schema "tags" do |
| 22 | field(:name, :string) | |
| 23 | field(:description, :string) | |
| 24 | field(:colour, :string) | |
| 25 | field(:fg_colour, :string) | |
| 26 | ||
| 27 | many_to_many(:timers, Klepsidra.TimeTracking.Timer, | |
| 28 | join_through: "timer_tags", | |
| 29 | on_replace: :delete, | |
| 30 | preload_order: [asc: :start_stamp] | |
| 31 | ) | |
| 32 | ||
| 33 | timestamps() | |
| 34 | end | |
| 35 | ||
| 36 | @doc false | |
| 37 | def changeset(tag, attrs) do | |
| 38 | tag | |
| 39 | |> cast(attrs, [:name, :description, :colour, :fg_colour]) | |
| 40 | |> validate_required([:name], message: "Enter a tag name") | |
| 41 | 26 | |> unique_constraint(:name, |
| 42 | name: :tags_name_index, | |
| 43 | message: "A tag with this name already exists" | |
| 44 | ) | |
| 45 | end | |
| 46 | ||
| 47 | @doc """ | |
| 48 | Finds tag list differences between the list of applied tags and | |
| 49 | those in the front-end component's accumulator list, calling | |
| 50 | functions responsible for adding and removing tags. | |
| 51 | """ | |
| 52 | @spec handle_tag_list_changes( | |
| 53 | list1 :: list(), | |
| 54 | list2 :: list(), | |
| 55 | entity_id :: bitstring(), | |
| 56 | insert_fun :: function(), | |
| 57 | delete_fun :: function() | |
| 58 | ) :: | |
| 59 | nil | |
| 60 | 0 | def handle_tag_list_changes([], [], _entity_id, _insert_fun, _delete_fun), do: nil |
| 61 | ||
| 62 | 0 | def handle_tag_list_changes(_list1, _list2, nil, _insert_fun, _delete_fun), do: nil |
| 63 | ||
| 64 | def handle_tag_list_changes(list1, list2, entity_id, insert_fun, delete_fun) | |
| 65 | when is_list(list1) and is_list(list2) and is_bitstring(entity_id) do | |
| 66 | 0 | deletion_list = list1 -- list2 |
| 67 | 0 | insertion_list = list2 -- list1 |
| 68 | ||
| 69 | 0 | handle_tag_actions(insertion_list, entity_id, insert_fun) |
| 70 | ||
| 71 | 0 | handle_tag_actions(deletion_list, entity_id, delete_fun) |
| 72 | end | |
| 73 | ||
| 74 | @doc """ | |
| 75 | """ | |
| 76 | @spec handle_tag_actions( | |
| 77 | action_list :: list(), | |
| 78 | entity_id :: bitstring(), | |
| 79 | insert_function :: function() | |
| 80 | ) :: nil | none() | |
| 81 | 0 | def handle_tag_actions([], _base_entity, _enumeration_function), do: nil |
| 82 | ||
| 83 | def handle_tag_actions({:ins, tag_id_list}, base_entity, enumeration_function), | |
| 84 | 0 | do: handle_tag_actions(tag_id_list, base_entity, enumeration_function) |
| 85 | ||
| 86 | def handle_tag_actions({:del, tag_id_list}, base_entity, enumeration_function), | |
| 87 | 0 | do: handle_tag_actions(tag_id_list, base_entity, enumeration_function) |
| 88 | ||
| 89 | def handle_tag_actions(tag_id_list, base_entity, enumeration_function) | |
| 90 | when is_list(tag_id_list) and is_function(enumeration_function) do | |
| 91 | tag_id_list | |
| 92 | 0 | |> Enum.map(fn tag_id -> |
| 93 | 0 | enumeration_function.(base_entity, tag_id) |
| 94 | end) | |
| 95 | end | |
| 96 | ||
| 97 | 0 | def handle_tag_actions(_, _base_entity, _enumeration_function), do: nil |
| 98 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Categorisation.TimerTags do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `TimerTags` entity, used to create a many-to-many | |
| 3 | relationship between timers and tags. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | alias Klepsidra.TimeTracking.Timer | |
| 10 | alias Klepsidra.Categorisation.Tag | |
| 11 | ||
| 12 | @primary_key false | |
| 13 | @foreign_key_type Ecto.UUID | |
| 14 | ||
| 15 | @type t :: %__MODULE__{ | |
| 16 | timer_id: Ecto.UUID.t(), | |
| 17 | tag_id: Ecto.UUID.t() | |
| 18 | } | |
| 19 | 0 | schema "timer_tags" do |
| 20 | belongs_to(:timer, Timer, primary_key: true, type: Ecto.UUID) | |
| 21 | belongs_to(:tag, Tag, primary_key: true, type: Ecto.UUID) | |
| 22 | ||
| 23 | timestamps() | |
| 24 | end | |
| 25 | ||
| 26 | @doc false | |
| 27 | def changeset(timer_tags, _attrs) do | |
| 28 | timer_tags | |
| 29 | |> unique_constraint([:timer, :tag], | |
| 30 | name: "timer_tags_timer_id_tag_id_index", | |
| 31 | message: "This tag has already been added to the timer" | |
| 32 | ) | |
| 33 | 0 | |> cast_assoc(:tag) |
| 34 | end | |
| 35 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.DynamicCSS do | |
| 1 | @moduledoc """ | |
| 2 | Generate CSS content based on your dynamic data (such as | |
| 3 | tag colors), saving it to a `.css` file in the `priv/static/assets` | |
| 4 | directory. | |
| 5 | """ | |
| 6 | ||
| 7 | alias Klepsidra.Categorisation | |
| 8 | ||
| 9 | @doc """ | |
| 10 | Generates tag styling classes for all tags, named `tag-<tag_name>`. | |
| 11 | """ | |
| 12 | @spec generate_tag_styles(tags :: [Klepsidra.Categorisation.Tag.t(), ...] | []) :: binary() | |
| 13 | def generate_tag_styles(tags) do | |
| 14 | 11 | Enum.map_join(tags, "\n", fn tag -> |
| 15 | 0 | generate_tag_style_declaration(tag) |
| 16 | end) | |
| 17 | end | |
| 18 | ||
| 19 | @doc """ | |
| 20 | Generates a single tag style declaration. | |
| 21 | """ | |
| 22 | @spec generate_tag_style_declaration(tag :: Klepsidra.Categorisation.Tag.t()) :: binary() | |
| 23 | def generate_tag_style_declaration(tag) when is_bitstring(tag), | |
| 24 | 0 | do: generate_tag_style_declaration(Categorisation.get_tag!(tag)) |
| 25 | ||
| 26 | def generate_tag_style_declaration(tag) when is_struct(tag, Klepsidra.Categorisation.Tag) do | |
| 27 | 0 | tag_class_name = convert_tag_name_to_class(tag.name) |
| 28 | ||
| 29 | 0 | fg_colour = if tag.fg_colour, do: tag.fg_colour, else: "#fff" |
| 30 | ||
| 31 | 0 | bg_colour = if tag.colour, do: tag.colour, else: "rgb(148, 163, 184)" |
| 32 | ||
| 33 | 0 | bg_colour_lowered_opacity = |
| 34 | 0 | if tag.colour, do: tag.colour <> "88", else: "rgba(255, 255, 255, 0.1)" |
| 35 | ||
| 36 | 0 | """ |
| 37 | 0 | .tag-#{tag_class_name}, .tag-#{tag_class_name} + button {background-color: #{bg_colour}; color: #{fg_colour};} |
| 38 | 0 | .tag-#{tag_class_name} + button {border-left: 2px solid #{bg_colour_lowered_opacity}; border-left-color: oklch(from #{bg_colour} calc(l + 0.1) c h);} |
| 39 | """ | |
| 40 | end | |
| 41 | ||
| 42 | @doc """ | |
| 43 | Converts tag names to CSS class-compliant format. | |
| 44 | """ | |
| 45 | @spec convert_tag_name_to_class(tag_name :: binary()) :: binary() | |
| 46 | def convert_tag_name_to_class(tag_name) when is_bitstring(tag_name) do | |
| 47 | tag_name | |
| 48 | |> String.split(" ") | |
| 49 | |> Enum.map(fn item -> | |
| 50 | 0 | String.replace( |
| 51 | item, | |
| 52 | [ | |
| 53 | "!", | |
| 54 | "£", | |
| 55 | "$", | |
| 56 | "%", | |
| 57 | "^", | |
| 58 | "&", | |
| 59 | "*", | |
| 60 | "(", | |
| 61 | ")", | |
| 62 | "[", | |
| 63 | "]", | |
| 64 | "{", | |
| 65 | "}", | |
| 66 | ":", | |
| 67 | ";", | |
| 68 | ",", | |
| 69 | ".", | |
| 70 | "/", | |
| 71 | "\\", | |
| 72 | "<", | |
| 73 | ">" | |
| 74 | ], | |
| 75 | "", | |
| 76 | global: true | |
| 77 | ) | |
| 78 | end) | |
| 79 | 0 | |> Enum.reject(fn item -> item == "" end) |
| 80 | 0 | |> Enum.join("_") |
| 81 | end | |
| 82 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Cldr do | |
| 1 | @moduledoc """ | |
| 2 | Defines additional time units encountered in the commercial world. | |
| 3 | ||
| 4 | Businesses record their time units in a range of time units, or billing increments, | |
| 5 | based on standard time primitives such as minutes and hours. Building on `Cldr.Units`, | |
| 6 | any desired time units must be defined here before they can be used in an application, | |
| 7 | since they must be compiled prior to use. | |
| 8 | ||
| 9 | Using the hour as a guide, practically all common divisions of the hour-in minutes-are | |
| 10 | defined here. This follows research carried out into time increments different industries | |
| 11 | choose to use to bill their clients in. | |
| 12 | """ | |
| 13 | ||
| 14 | use Cldr.Unit.Additional | |
| 15 | ||
| 16 | use Cldr, | |
| 17 | locales: ["en", "en_AU", "en_CA", "en_GB"], | |
| 18 | default_locale: "en_GB", | |
| 19 | providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime, Cldr.Unit, Cldr.List] | |
| 20 | ||
| 21 | # "en" locale | |
| 22 | # minute increment | |
| 23 | unit_localization(:minute_increment, "en", :long, | |
| 24 | nominative: %{ | |
| 25 | one: "{0} minute", | |
| 26 | other: "{0} minutes" | |
| 27 | }, | |
| 28 | display_name: "Minutes" | |
| 29 | ) | |
| 30 | ||
| 31 | unit_localization(:minute_increment, "en", :short, | |
| 32 | nominative: %{ | |
| 33 | one: "{0} min", | |
| 34 | other: "{0} mins" | |
| 35 | }, | |
| 36 | display_name: "Minutes" | |
| 37 | ) | |
| 38 | ||
| 39 | unit_localization(:minute_increment, "en", :narrow, | |
| 40 | nominative: %{ | |
| 41 | one: "{0} min", | |
| 42 | other: "{0} mins" | |
| 43 | }, | |
| 44 | display_name: "Minutes" | |
| 45 | ) | |
| 46 | ||
| 47 | # 5 minute increment | |
| 48 | unit_localization(:five_minute_increment, "en", :long, | |
| 49 | nominative: %{ | |
| 50 | one: "{0} five minute increment", | |
| 51 | other: "{0} five minute increments" | |
| 52 | }, | |
| 53 | display_name: "5 minutes" | |
| 54 | ) | |
| 55 | ||
| 56 | unit_localization(:five_minute_increment, "en", :short, | |
| 57 | nominative: %{ | |
| 58 | one: "{0} five mins", | |
| 59 | other: "{0} five mins" | |
| 60 | }, | |
| 61 | display_name: "5 mins" | |
| 62 | ) | |
| 63 | ||
| 64 | unit_localization(:five_minute_increment, "en", :narrow, | |
| 65 | nominative: %{ | |
| 66 | one: "{0} five min", | |
| 67 | other: "{0} five min" | |
| 68 | }, | |
| 69 | display_name: "5 min" | |
| 70 | ) | |
| 71 | ||
| 72 | # 6 minute increment | |
| 73 | unit_localization(:six_minute_increment, "en", :long, | |
| 74 | nominative: %{ | |
| 75 | one: "{0} six minute increment", | |
| 76 | other: "{0} six minute increments" | |
| 77 | }, | |
| 78 | display_name: "6 minutes" | |
| 79 | ) | |
| 80 | ||
| 81 | unit_localization(:six_minute_increment, "en", :short, | |
| 82 | nominative: %{ | |
| 83 | one: "{0} six mins", | |
| 84 | other: "{0} six mins" | |
| 85 | }, | |
| 86 | display_name: "6 mins" | |
| 87 | ) | |
| 88 | ||
| 89 | unit_localization(:six_minute_increment, "en", :narrow, | |
| 90 | nominative: %{ | |
| 91 | one: "{0} six min", | |
| 92 | other: "{0} six min" | |
| 93 | }, | |
| 94 | display_name: "6 min" | |
| 95 | ) | |
| 96 | ||
| 97 | # 10 minute increment | |
| 98 | unit_localization(:ten_minute_increment, "en", :long, | |
| 99 | nominative: %{ | |
| 100 | one: "{0} ten minute increment", | |
| 101 | other: "{0} ten minute increments" | |
| 102 | }, | |
| 103 | display_name: "10 minute increment" | |
| 104 | ) | |
| 105 | ||
| 106 | unit_localization(:ten_minute_increment, "en", :short, | |
| 107 | nominative: %{ | |
| 108 | one: "{0} ten mins", | |
| 109 | other: "{0} ten mins" | |
| 110 | }, | |
| 111 | display_name: "10 mins" | |
| 112 | ) | |
| 113 | ||
| 114 | unit_localization(:ten_minute_increment, "en", :narrow, | |
| 115 | nominative: %{ | |
| 116 | one: "{0} ten min", | |
| 117 | other: "{0} ten min" | |
| 118 | }, | |
| 119 | display_name: "10 min" | |
| 120 | ) | |
| 121 | ||
| 122 | # 12 minute increment | |
| 123 | unit_localization(:twelve_minute_increment, "en", :long, | |
| 124 | nominative: %{ | |
| 125 | one: "{0} twelve minute increment", | |
| 126 | other: "{0} twelve minute increments" | |
| 127 | }, | |
| 128 | display_name: "12 minute increment" | |
| 129 | ) | |
| 130 | ||
| 131 | unit_localization(:twelve_minute_increment, "en", :short, | |
| 132 | nominative: %{ | |
| 133 | one: "{0} twelve mins", | |
| 134 | other: "{0} twelve mins" | |
| 135 | }, | |
| 136 | display_name: "12 mins" | |
| 137 | ) | |
| 138 | ||
| 139 | unit_localization(:twelve_minute_increment, "en", :narrow, | |
| 140 | nominative: %{ | |
| 141 | one: "{0} twelve min", | |
| 142 | other: "{0} twelve min" | |
| 143 | }, | |
| 144 | display_name: "12 min" | |
| 145 | ) | |
| 146 | ||
| 147 | # 15 minute increment | |
| 148 | unit_localization(:fifteen_minute_increment, "en", :long, | |
| 149 | nominative: %{ | |
| 150 | one: "{0} fifteen minute increment", | |
| 151 | other: "{0} fifteen minute increments" | |
| 152 | }, | |
| 153 | display_name: "15 minute increment" | |
| 154 | ) | |
| 155 | ||
| 156 | unit_localization(:fifteen_minute_increment, "en", :short, | |
| 157 | nominative: %{ | |
| 158 | one: "{0} fifteen mins", | |
| 159 | other: "{0} fifteen mins" | |
| 160 | }, | |
| 161 | display_name: "15 mins" | |
| 162 | ) | |
| 163 | ||
| 164 | unit_localization(:fifteen_minute_increment, "en", :narrow, | |
| 165 | nominative: %{ | |
| 166 | one: "{0} fifteen min", | |
| 167 | other: "{0} fifteen min" | |
| 168 | }, | |
| 169 | display_name: "15 min" | |
| 170 | ) | |
| 171 | ||
| 172 | # 18 minute increment | |
| 173 | unit_localization(:eighteen_minute_increment, "en", :long, | |
| 174 | nominative: %{ | |
| 175 | one: "{0} eighteen minute increment", | |
| 176 | other: "{0} eighteen minute increments" | |
| 177 | }, | |
| 178 | display_name: "18 minute increment" | |
| 179 | ) | |
| 180 | ||
| 181 | unit_localization(:eighteen_minute_increment, "en", :short, | |
| 182 | nominative: %{ | |
| 183 | one: "{0} eighteen mins", | |
| 184 | other: "{0} eighteen mins" | |
| 185 | }, | |
| 186 | display_name: "18 mins" | |
| 187 | ) | |
| 188 | ||
| 189 | unit_localization(:eighteen_minute_increment, "en", :narrow, | |
| 190 | nominative: %{ | |
| 191 | one: "{0} eighteen min", | |
| 192 | other: "{0} eighteen min" | |
| 193 | }, | |
| 194 | display_name: "18 min" | |
| 195 | ) | |
| 196 | ||
| 197 | # 20 minute increment | |
| 198 | unit_localization(:twenty_minute_increment, "en", :long, | |
| 199 | nominative: %{ | |
| 200 | one: "{0} twenty minute increment", | |
| 201 | other: "{0} twenty minute increments" | |
| 202 | }, | |
| 203 | display_name: "20 minute increment" | |
| 204 | ) | |
| 205 | ||
| 206 | unit_localization(:twenty_minute_increment, "en", :short, | |
| 207 | nominative: %{ | |
| 208 | one: "{0} twenty mins", | |
| 209 | other: "{0} twenty mins" | |
| 210 | }, | |
| 211 | display_name: "20 mins" | |
| 212 | ) | |
| 213 | ||
| 214 | unit_localization(:twenty_minute_increment, "en", :narrow, | |
| 215 | nominative: %{ | |
| 216 | one: "{0} twenty min", | |
| 217 | other: "{0} twenty min" | |
| 218 | }, | |
| 219 | display_name: "20 min" | |
| 220 | ) | |
| 221 | ||
| 222 | # 24 minute increment | |
| 223 | unit_localization(:twenty_four_minute_increment, "en", :long, | |
| 224 | nominative: %{ | |
| 225 | one: "{0} twenty-four minute increment", | |
| 226 | other: "{0} twenty-four minute increments" | |
| 227 | }, | |
| 228 | display_name: "24 minute increment" | |
| 229 | ) | |
| 230 | ||
| 231 | unit_localization(:twenty_four_minute_increment, "en", :short, | |
| 232 | nominative: %{ | |
| 233 | one: "{0} twenty-four mins", | |
| 234 | other: "{0} twenty-four mins" | |
| 235 | }, | |
| 236 | display_name: "24 mins" | |
| 237 | ) | |
| 238 | ||
| 239 | unit_localization(:twenty_four_minute_increment, "en", :narrow, | |
| 240 | nominative: %{ | |
| 241 | one: "{0} twenty-four min", | |
| 242 | other: "{0} twenty-four min" | |
| 243 | }, | |
| 244 | display_name: "24 min" | |
| 245 | ) | |
| 246 | ||
| 247 | # 30 minute increment | |
| 248 | unit_localization(:thirty_minute_increment, "en", :long, | |
| 249 | nominative: %{ | |
| 250 | one: "{0} thirty minute increment", | |
| 251 | other: "{0} thirty minute increments" | |
| 252 | }, | |
| 253 | display_name: "30 minute increment" | |
| 254 | ) | |
| 255 | ||
| 256 | unit_localization(:thirty_minute_increment, "en", :short, | |
| 257 | nominative: %{ | |
| 258 | one: "{0} thirty mins", | |
| 259 | other: "{0} thirty mins" | |
| 260 | }, | |
| 261 | display_name: "30 mins" | |
| 262 | ) | |
| 263 | ||
| 264 | unit_localization(:thirty_minute_increment, "en", :narrow, | |
| 265 | nominative: %{ | |
| 266 | one: "{0} thirty min", | |
| 267 | other: "{0} thirty min" | |
| 268 | }, | |
| 269 | display_name: "30 min" | |
| 270 | ) | |
| 271 | ||
| 272 | # 36 minute increment | |
| 273 | unit_localization(:thirty_six_minute_increment, "en", :long, | |
| 274 | nominative: %{ | |
| 275 | one: "{0} thirty-six minute increment", | |
| 276 | other: "{0} thirty-six minute increments" | |
| 277 | }, | |
| 278 | display_name: "36 minute increment" | |
| 279 | ) | |
| 280 | ||
| 281 | unit_localization(:thirty_six_minute_increment, "en", :short, | |
| 282 | nominative: %{ | |
| 283 | one: "{0} thirty-six mins", | |
| 284 | other: "{0} thirty-six mins" | |
| 285 | }, | |
| 286 | display_name: "36 mins" | |
| 287 | ) | |
| 288 | ||
| 289 | unit_localization(:thirty_six_minute_increment, "en", :narrow, | |
| 290 | nominative: %{ | |
| 291 | one: "{0} thirty-six min", | |
| 292 | other: "{0} thirty-six min" | |
| 293 | }, | |
| 294 | display_name: "36 min" | |
| 295 | ) | |
| 296 | ||
| 297 | # 45 minute increment | |
| 298 | unit_localization(:fourty_five_minute_increment, "en", :long, | |
| 299 | nominative: %{ | |
| 300 | one: "{0} fourty-five minute increment", | |
| 301 | other: "{0} fourty-five minute increments" | |
| 302 | }, | |
| 303 | display_name: "45 minute increment" | |
| 304 | ) | |
| 305 | ||
| 306 | unit_localization(:fourty_five_minute_increment, "en", :short, | |
| 307 | nominative: %{ | |
| 308 | one: "{0} fourty-five mins", | |
| 309 | other: "{0} fourty-five mins" | |
| 310 | }, | |
| 311 | display_name: "45 mins" | |
| 312 | ) | |
| 313 | ||
| 314 | unit_localization(:fourty_five_minute_increment, "en", :narrow, | |
| 315 | nominative: %{ | |
| 316 | one: "{0} fourty-five min", | |
| 317 | other: "{0} fourty-five min" | |
| 318 | }, | |
| 319 | display_name: "45 min" | |
| 320 | ) | |
| 321 | ||
| 322 | # 60 minute increment | |
| 323 | unit_localization(:sixty_minute_increment, "en", :long, | |
| 324 | nominative: %{ | |
| 325 | one: "{0} hour", | |
| 326 | other: "{0} hours" | |
| 327 | }, | |
| 328 | display_name: "60 minute increment" | |
| 329 | ) | |
| 330 | ||
| 331 | unit_localization(:sixty_minute_increment, "en", :short, | |
| 332 | nominative: %{ | |
| 333 | one: "{0} sixty mins", | |
| 334 | other: "{0} sixty mins" | |
| 335 | }, | |
| 336 | display_name: "60 mins" | |
| 337 | ) | |
| 338 | ||
| 339 | unit_localization(:sixty_minute_increment, "en", :narrow, | |
| 340 | nominative: %{ | |
| 341 | one: "{0} sixty min", | |
| 342 | other: "{0} sixty min" | |
| 343 | }, | |
| 344 | display_name: "60 min" | |
| 345 | ) | |
| 346 | ||
| 347 | # hour increment | |
| 348 | unit_localization(:hour_increment, "en", :long, | |
| 349 | nominative: %{ | |
| 350 | one: "{0} hour", | |
| 351 | other: "{0} hours" | |
| 352 | }, | |
| 353 | display_name: "hour" | |
| 354 | ) | |
| 355 | ||
| 356 | unit_localization(:hour_increment, "en", :short, | |
| 357 | nominative: %{ | |
| 358 | one: "{0} hour", | |
| 359 | other: "{0} hours" | |
| 360 | }, | |
| 361 | display_name: "hour" | |
| 362 | ) | |
| 363 | ||
| 364 | unit_localization(:hour_increment, "en", :narrow, | |
| 365 | nominative: %{ | |
| 366 | one: "{0} hr", | |
| 367 | other: "{0} hrs" | |
| 368 | }, | |
| 369 | display_name: "hour" | |
| 370 | ) | |
| 371 | ||
| 372 | # 90 minute increment | |
| 373 | unit_localization(:ninety_minute_increment, "en", :long, | |
| 374 | nominative: %{ | |
| 375 | one: "{0} ninety minute increment", | |
| 376 | other: "{0} ninety minute increments" | |
| 377 | }, | |
| 378 | display_name: "90 minute increment" | |
| 379 | ) | |
| 380 | ||
| 381 | unit_localization(:ninety_minute_increment, "en", :short, | |
| 382 | nominative: %{ | |
| 383 | one: "{0} ninety mins", | |
| 384 | other: "{0} ninety mins" | |
| 385 | }, | |
| 386 | display_name: "90 mins" | |
| 387 | ) | |
| 388 | ||
| 389 | unit_localization(:ninety_minute_increment, "en", :narrow, | |
| 390 | nominative: %{ | |
| 391 | one: "{0} ninety min", | |
| 392 | other: "{0} ninety min" | |
| 393 | }, | |
| 394 | display_name: "90 min" | |
| 395 | ) | |
| 396 | ||
| 397 | # 120 minute increment | |
| 398 | unit_localization(:one_hundred_twenty_minute_increment, "en", :long, | |
| 399 | nominative: %{ | |
| 400 | one: "{0} one hundred twenty minute increment", | |
| 401 | other: "{0} one hundred twenty minute increments" | |
| 402 | }, | |
| 403 | display_name: "2 hour increment" | |
| 404 | ) | |
| 405 | ||
| 406 | unit_localization(:one_hundred_twenty_minute_increment, "en", :short, | |
| 407 | nominative: %{ | |
| 408 | one: "{0} one-twenty mins", | |
| 409 | other: "{0} one-twenty mins" | |
| 410 | }, | |
| 411 | display_name: "2 hours" | |
| 412 | ) | |
| 413 | ||
| 414 | unit_localization(:one_hundred_twenty_minute_increment, "en", :narrow, | |
| 415 | nominative: %{ | |
| 416 | one: "{0} one-twenty min", | |
| 417 | other: "{0} one-twenty min" | |
| 418 | }, | |
| 419 | display_name: "2 hour increment" | |
| 420 | ) | |
| 421 | ||
| 422 | # "en-AU" locale | |
| 423 | # minute increment | |
| 424 | unit_localization(:minute_increment, "en-AU", :long, | |
| 425 | nominative: %{ | |
| 426 | one: "{0} minute", | |
| 427 | other: "{0} minutes" | |
| 428 | }, | |
| 429 | display_name: "Minutes" | |
| 430 | ) | |
| 431 | ||
| 432 | unit_localization(:minute_increment, "en-AU", :short, | |
| 433 | nominative: %{ | |
| 434 | one: "{0} min", | |
| 435 | other: "{0} mins" | |
| 436 | }, | |
| 437 | display_name: "Minutes" | |
| 438 | ) | |
| 439 | ||
| 440 | unit_localization(:minute_increment, "en-AU", :narrow, | |
| 441 | nominative: %{ | |
| 442 | one: "{0} min", | |
| 443 | other: "{0} mins" | |
| 444 | }, | |
| 445 | display_name: "Minutes" | |
| 446 | ) | |
| 447 | ||
| 448 | # 5 minute increment | |
| 449 | unit_localization(:five_minute_increment, "en-AU", :long, | |
| 450 | nominative: %{ | |
| 451 | one: "{0} five minute increment", | |
| 452 | other: "{0} five minute increments" | |
| 453 | }, | |
| 454 | display_name: "5 minutes" | |
| 455 | ) | |
| 456 | ||
| 457 | unit_localization(:five_minute_increment, "en-AU", :short, | |
| 458 | nominative: %{ | |
| 459 | one: "{0} five mins", | |
| 460 | other: "{0} five mins" | |
| 461 | }, | |
| 462 | display_name: "5 mins" | |
| 463 | ) | |
| 464 | ||
| 465 | unit_localization(:five_minute_increment, "en-AU", :narrow, | |
| 466 | nominative: %{ | |
| 467 | one: "{0} five min", | |
| 468 | other: "{0} five min" | |
| 469 | }, | |
| 470 | display_name: "5 min" | |
| 471 | ) | |
| 472 | ||
| 473 | # 6 minute increment | |
| 474 | unit_localization(:six_minute_increment, "en-AU", :long, | |
| 475 | nominative: %{ | |
| 476 | one: "{0} six minute increment", | |
| 477 | other: "{0} six minute increments" | |
| 478 | }, | |
| 479 | display_name: "6 minutes" | |
| 480 | ) | |
| 481 | ||
| 482 | unit_localization(:six_minute_increment, "en-AU", :short, | |
| 483 | nominative: %{ | |
| 484 | one: "{0} six mins", | |
| 485 | other: "{0} six mins" | |
| 486 | }, | |
| 487 | display_name: "6 mins" | |
| 488 | ) | |
| 489 | ||
| 490 | unit_localization(:six_minute_increment, "en-AU", :narrow, | |
| 491 | nominative: %{ | |
| 492 | one: "{0} six min", | |
| 493 | other: "{0} six min" | |
| 494 | }, | |
| 495 | display_name: "6 min" | |
| 496 | ) | |
| 497 | ||
| 498 | # 10 minute increment | |
| 499 | unit_localization(:ten_minute_increment, "en-AU", :long, | |
| 500 | nominative: %{ | |
| 501 | one: "{0} ten minute increment", | |
| 502 | other: "{0} ten minute increments" | |
| 503 | }, | |
| 504 | display_name: "10 minute increment" | |
| 505 | ) | |
| 506 | ||
| 507 | unit_localization(:ten_minute_increment, "en-AU", :short, | |
| 508 | nominative: %{ | |
| 509 | one: "{0} ten mins", | |
| 510 | other: "{0} ten mins" | |
| 511 | }, | |
| 512 | display_name: "10 mins" | |
| 513 | ) | |
| 514 | ||
| 515 | unit_localization(:ten_minute_increment, "en-AU", :narrow, | |
| 516 | nominative: %{ | |
| 517 | one: "{0} ten min", | |
| 518 | other: "{0} ten min" | |
| 519 | }, | |
| 520 | display_name: "10 min" | |
| 521 | ) | |
| 522 | ||
| 523 | # 12 minute increment | |
| 524 | unit_localization(:twelve_minute_increment, "en-AU", :long, | |
| 525 | nominative: %{ | |
| 526 | one: "{0} twelve minute increment", | |
| 527 | other: "{0} twelve minute increments" | |
| 528 | }, | |
| 529 | display_name: "12 minute increment" | |
| 530 | ) | |
| 531 | ||
| 532 | unit_localization(:twelve_minute_increment, "en-AU", :short, | |
| 533 | nominative: %{ | |
| 534 | one: "{0} twelve mins", | |
| 535 | other: "{0} twelve mins" | |
| 536 | }, | |
| 537 | display_name: "12 mins" | |
| 538 | ) | |
| 539 | ||
| 540 | unit_localization(:twelve_minute_increment, "en-AU", :narrow, | |
| 541 | nominative: %{ | |
| 542 | one: "{0} twelve min", | |
| 543 | other: "{0} twelve min" | |
| 544 | }, | |
| 545 | display_name: "12 min" | |
| 546 | ) | |
| 547 | ||
| 548 | # 15 minute increment | |
| 549 | unit_localization(:fifteen_minute_increment, "en-AU", :long, | |
| 550 | nominative: %{ | |
| 551 | one: "{0} fifteen minute increment", | |
| 552 | other: "{0} fifteen minute increments" | |
| 553 | }, | |
| 554 | display_name: "15 minute increment" | |
| 555 | ) | |
| 556 | ||
| 557 | unit_localization(:fifteen_minute_increment, "en-AU", :short, | |
| 558 | nominative: %{ | |
| 559 | one: "{0} fifteen mins", | |
| 560 | other: "{0} fifteen mins" | |
| 561 | }, | |
| 562 | display_name: "15 mins" | |
| 563 | ) | |
| 564 | ||
| 565 | unit_localization(:fifteen_minute_increment, "en-AU", :narrow, | |
| 566 | nominative: %{ | |
| 567 | one: "{0} fifteen min", | |
| 568 | other: "{0} fifteen min" | |
| 569 | }, | |
| 570 | display_name: "15 min" | |
| 571 | ) | |
| 572 | ||
| 573 | # 18 minute increment | |
| 574 | unit_localization(:eighteen_minute_increment, "en-AU", :long, | |
| 575 | nominative: %{ | |
| 576 | one: "{0} eighteen minute increment", | |
| 577 | other: "{0} eighteen minute increments" | |
| 578 | }, | |
| 579 | display_name: "18 minute increment" | |
| 580 | ) | |
| 581 | ||
| 582 | unit_localization(:eighteen_minute_increment, "en-AU", :short, | |
| 583 | nominative: %{ | |
| 584 | one: "{0} eighteen mins", | |
| 585 | other: "{0} eighteen mins" | |
| 586 | }, | |
| 587 | display_name: "18 mins" | |
| 588 | ) | |
| 589 | ||
| 590 | unit_localization(:eighteen_minute_increment, "en-AU", :narrow, | |
| 591 | nominative: %{ | |
| 592 | one: "{0} eighteen min", | |
| 593 | other: "{0} eighteen min" | |
| 594 | }, | |
| 595 | display_name: "18 min" | |
| 596 | ) | |
| 597 | ||
| 598 | # 20 minute increment | |
| 599 | unit_localization(:twenty_minute_increment, "en-AU", :long, | |
| 600 | nominative: %{ | |
| 601 | one: "{0} twenty minute increment", | |
| 602 | other: "{0} twenty minute increments" | |
| 603 | }, | |
| 604 | display_name: "20 minute increment" | |
| 605 | ) | |
| 606 | ||
| 607 | unit_localization(:twenty_minute_increment, "en-AU", :short, | |
| 608 | nominative: %{ | |
| 609 | one: "{0} twenty mins", | |
| 610 | other: "{0} twenty mins" | |
| 611 | }, | |
| 612 | display_name: "20 mins" | |
| 613 | ) | |
| 614 | ||
| 615 | unit_localization(:twenty_minute_increment, "en-AU", :narrow, | |
| 616 | nominative: %{ | |
| 617 | one: "{0} twenty min", | |
| 618 | other: "{0} twenty min" | |
| 619 | }, | |
| 620 | display_name: "20 min" | |
| 621 | ) | |
| 622 | ||
| 623 | # 24 minute increment | |
| 624 | unit_localization(:twenty_four_minute_increment, "en-AU", :long, | |
| 625 | nominative: %{ | |
| 626 | one: "{0} twenty-four minute increment", | |
| 627 | other: "{0} twenty-four minute increments" | |
| 628 | }, | |
| 629 | display_name: "24 minute increment" | |
| 630 | ) | |
| 631 | ||
| 632 | unit_localization(:twenty_four_minute_increment, "en-AU", :short, | |
| 633 | nominative: %{ | |
| 634 | one: "{0} twenty-four mins", | |
| 635 | other: "{0} twenty-four mins" | |
| 636 | }, | |
| 637 | display_name: "24 mins" | |
| 638 | ) | |
| 639 | ||
| 640 | unit_localization(:twenty_four_minute_increment, "en-AU", :narrow, | |
| 641 | nominative: %{ | |
| 642 | one: "{0} twenty-four min", | |
| 643 | other: "{0} twenty-four min" | |
| 644 | }, | |
| 645 | display_name: "24 min" | |
| 646 | ) | |
| 647 | ||
| 648 | # 30 minute increment | |
| 649 | unit_localization(:thirty_minute_increment, "en-AU", :long, | |
| 650 | nominative: %{ | |
| 651 | one: "{0} thirty minute increment", | |
| 652 | other: "{0} thirty minute increments" | |
| 653 | }, | |
| 654 | display_name: "30 minute increment" | |
| 655 | ) | |
| 656 | ||
| 657 | unit_localization(:thirty_minute_increment, "en-AU", :short, | |
| 658 | nominative: %{ | |
| 659 | one: "{0} thirty mins", | |
| 660 | other: "{0} thirty mins" | |
| 661 | }, | |
| 662 | display_name: "30 mins" | |
| 663 | ) | |
| 664 | ||
| 665 | unit_localization(:thirty_minute_increment, "en-AU", :narrow, | |
| 666 | nominative: %{ | |
| 667 | one: "{0} thirty min", | |
| 668 | other: "{0} thirty min" | |
| 669 | }, | |
| 670 | display_name: "30 min" | |
| 671 | ) | |
| 672 | ||
| 673 | # 36 minute increment | |
| 674 | unit_localization(:thirty_six_minute_increment, "en-AU", :long, | |
| 675 | nominative: %{ | |
| 676 | one: "{0} thirty-six minute increment", | |
| 677 | other: "{0} thirty-six minute increments" | |
| 678 | }, | |
| 679 | display_name: "36 minute increment" | |
| 680 | ) | |
| 681 | ||
| 682 | unit_localization(:thirty_six_minute_increment, "en-AU", :short, | |
| 683 | nominative: %{ | |
| 684 | one: "{0} thirty-six mins", | |
| 685 | other: "{0} thirty-six mins" | |
| 686 | }, | |
| 687 | display_name: "36 mins" | |
| 688 | ) | |
| 689 | ||
| 690 | unit_localization(:thirty_six_minute_increment, "en-AU", :narrow, | |
| 691 | nominative: %{ | |
| 692 | one: "{0} thirty-six min", | |
| 693 | other: "{0} thirty-six min" | |
| 694 | }, | |
| 695 | display_name: "36 min" | |
| 696 | ) | |
| 697 | ||
| 698 | # 45 minute increment | |
| 699 | unit_localization(:fourty_five_minute_increment, "en-AU", :long, | |
| 700 | nominative: %{ | |
| 701 | one: "{0} fourty-five minute increment", | |
| 702 | other: "{0} fourty-five minute increments" | |
| 703 | }, | |
| 704 | display_name: "45 minute increment" | |
| 705 | ) | |
| 706 | ||
| 707 | unit_localization(:fourty_five_minute_increment, "en-AU", :short, | |
| 708 | nominative: %{ | |
| 709 | one: "{0} fourty-five mins", | |
| 710 | other: "{0} fourty-five mins" | |
| 711 | }, | |
| 712 | display_name: "45 mins" | |
| 713 | ) | |
| 714 | ||
| 715 | unit_localization(:fourty_five_minute_increment, "en-AU", :narrow, | |
| 716 | nominative: %{ | |
| 717 | one: "{0} fourty-five min", | |
| 718 | other: "{0} fourty-five min" | |
| 719 | }, | |
| 720 | display_name: "45 min" | |
| 721 | ) | |
| 722 | ||
| 723 | # 60 minute increment | |
| 724 | unit_localization(:sixty_minute_increment, "en-AU", :long, | |
| 725 | nominative: %{ | |
| 726 | one: "{0} hour", | |
| 727 | other: "{0} hours" | |
| 728 | }, | |
| 729 | display_name: "60 minute increment" | |
| 730 | ) | |
| 731 | ||
| 732 | unit_localization(:sixty_minute_increment, "en-AU", :short, | |
| 733 | nominative: %{ | |
| 734 | one: "{0} sixty mins", | |
| 735 | other: "{0} sixty mins" | |
| 736 | }, | |
| 737 | display_name: "60 mins" | |
| 738 | ) | |
| 739 | ||
| 740 | unit_localization(:sixty_minute_increment, "en-AU", :narrow, | |
| 741 | nominative: %{ | |
| 742 | one: "{0} sixty min", | |
| 743 | other: "{0} sixty min" | |
| 744 | }, | |
| 745 | display_name: "60 min" | |
| 746 | ) | |
| 747 | ||
| 748 | # hour increment | |
| 749 | unit_localization(:hour_increment, "en-AU", :long, | |
| 750 | nominative: %{ | |
| 751 | one: "{0} hour", | |
| 752 | other: "{0} hours" | |
| 753 | }, | |
| 754 | display_name: "hour" | |
| 755 | ) | |
| 756 | ||
| 757 | unit_localization(:hour_increment, "en-AU", :short, | |
| 758 | nominative: %{ | |
| 759 | one: "{0} hour", | |
| 760 | other: "{0} hours" | |
| 761 | }, | |
| 762 | display_name: "hour" | |
| 763 | ) | |
| 764 | ||
| 765 | unit_localization(:hour_increment, "en-AU", :narrow, | |
| 766 | nominative: %{ | |
| 767 | one: "{0} hr", | |
| 768 | other: "{0} hrs" | |
| 769 | }, | |
| 770 | display_name: "hour" | |
| 771 | ) | |
| 772 | ||
| 773 | # 90 minute increment | |
| 774 | unit_localization(:ninety_minute_increment, "en-AU", :long, | |
| 775 | nominative: %{ | |
| 776 | one: "{0} ninety minute increment", | |
| 777 | other: "{0} ninety minute increments" | |
| 778 | }, | |
| 779 | display_name: "90 minute increment" | |
| 780 | ) | |
| 781 | ||
| 782 | unit_localization(:ninety_minute_increment, "en-AU", :short, | |
| 783 | nominative: %{ | |
| 784 | one: "{0} ninety mins", | |
| 785 | other: "{0} ninety mins" | |
| 786 | }, | |
| 787 | display_name: "90 mins" | |
| 788 | ) | |
| 789 | ||
| 790 | unit_localization(:ninety_minute_increment, "en-AU", :narrow, | |
| 791 | nominative: %{ | |
| 792 | one: "{0} ninety min", | |
| 793 | other: "{0} ninety min" | |
| 794 | }, | |
| 795 | display_name: "90 min" | |
| 796 | ) | |
| 797 | ||
| 798 | # 120 minute increment | |
| 799 | unit_localization(:one_hundred_twenty_minute_increment, "en-AU", :long, | |
| 800 | nominative: %{ | |
| 801 | one: "{0} one hundred twenty minute increment", | |
| 802 | other: "{0} one hundred twenty minute increments" | |
| 803 | }, | |
| 804 | display_name: "2 hour increment" | |
| 805 | ) | |
| 806 | ||
| 807 | unit_localization(:one_hundred_twenty_minute_increment, "en-AU", :short, | |
| 808 | nominative: %{ | |
| 809 | one: "{0} one-twenty mins", | |
| 810 | other: "{0} one-twenty mins" | |
| 811 | }, | |
| 812 | display_name: "2 hours" | |
| 813 | ) | |
| 814 | ||
| 815 | unit_localization(:one_hundred_twenty_minute_increment, "en-AU", :narrow, | |
| 816 | nominative: %{ | |
| 817 | one: "{0} one-twenty min", | |
| 818 | other: "{0} one-twenty min" | |
| 819 | }, | |
| 820 | display_name: "2 hour increment" | |
| 821 | ) | |
| 822 | ||
| 823 | # "en-CA" locale | |
| 824 | # minute increment | |
| 825 | unit_localization(:minute_increment, "en-CA", :long, | |
| 826 | nominative: %{ | |
| 827 | one: "{0} minute", | |
| 828 | other: "{0} minutes" | |
| 829 | }, | |
| 830 | display_name: "Minutes" | |
| 831 | ) | |
| 832 | ||
| 833 | unit_localization(:minute_increment, "en-CA", :short, | |
| 834 | nominative: %{ | |
| 835 | one: "{0} min", | |
| 836 | other: "{0} mins" | |
| 837 | }, | |
| 838 | display_name: "Minutes" | |
| 839 | ) | |
| 840 | ||
| 841 | unit_localization(:minute_increment, "en-CA", :narrow, | |
| 842 | nominative: %{ | |
| 843 | one: "{0} min", | |
| 844 | other: "{0} mins" | |
| 845 | }, | |
| 846 | display_name: "Minutes" | |
| 847 | ) | |
| 848 | ||
| 849 | # 5 minute increment | |
| 850 | unit_localization(:five_minute_increment, "en-CA", :long, | |
| 851 | nominative: %{ | |
| 852 | one: "{0} five minute increment", | |
| 853 | other: "{0} five minute increments" | |
| 854 | }, | |
| 855 | display_name: "5 minutes" | |
| 856 | ) | |
| 857 | ||
| 858 | unit_localization(:five_minute_increment, "en-CA", :short, | |
| 859 | nominative: %{ | |
| 860 | one: "{0} five mins", | |
| 861 | other: "{0} five mins" | |
| 862 | }, | |
| 863 | display_name: "5 mins" | |
| 864 | ) | |
| 865 | ||
| 866 | unit_localization(:five_minute_increment, "en-CA", :narrow, | |
| 867 | nominative: %{ | |
| 868 | one: "{0} five min", | |
| 869 | other: "{0} five min" | |
| 870 | }, | |
| 871 | display_name: "5 min" | |
| 872 | ) | |
| 873 | ||
| 874 | # 6 minute increment | |
| 875 | unit_localization(:six_minute_increment, "en-CA", :long, | |
| 876 | nominative: %{ | |
| 877 | one: "{0} six minute increment", | |
| 878 | other: "{0} six minute increments" | |
| 879 | }, | |
| 880 | display_name: "6 minutes" | |
| 881 | ) | |
| 882 | ||
| 883 | unit_localization(:six_minute_increment, "en-CA", :short, | |
| 884 | nominative: %{ | |
| 885 | one: "{0} six mins", | |
| 886 | other: "{0} six mins" | |
| 887 | }, | |
| 888 | display_name: "6 mins" | |
| 889 | ) | |
| 890 | ||
| 891 | unit_localization(:six_minute_increment, "en-CA", :narrow, | |
| 892 | nominative: %{ | |
| 893 | one: "{0} six min", | |
| 894 | other: "{0} six min" | |
| 895 | }, | |
| 896 | display_name: "6 min" | |
| 897 | ) | |
| 898 | ||
| 899 | # 10 minute increment | |
| 900 | unit_localization(:ten_minute_increment, "en-CA", :long, | |
| 901 | nominative: %{ | |
| 902 | one: "{0} ten minute increment", | |
| 903 | other: "{0} ten minute increments" | |
| 904 | }, | |
| 905 | display_name: "10 minute increment" | |
| 906 | ) | |
| 907 | ||
| 908 | unit_localization(:ten_minute_increment, "en-CA", :short, | |
| 909 | nominative: %{ | |
| 910 | one: "{0} ten mins", | |
| 911 | other: "{0} ten mins" | |
| 912 | }, | |
| 913 | display_name: "10 mins" | |
| 914 | ) | |
| 915 | ||
| 916 | unit_localization(:ten_minute_increment, "en-CA", :narrow, | |
| 917 | nominative: %{ | |
| 918 | one: "{0} ten min", | |
| 919 | other: "{0} ten min" | |
| 920 | }, | |
| 921 | display_name: "10 min" | |
| 922 | ) | |
| 923 | ||
| 924 | # 12 minute increment | |
| 925 | unit_localization(:twelve_minute_increment, "en-CA", :long, | |
| 926 | nominative: %{ | |
| 927 | one: "{0} twelve minute increment", | |
| 928 | other: "{0} twelve minute increments" | |
| 929 | }, | |
| 930 | display_name: "12 minute increment" | |
| 931 | ) | |
| 932 | ||
| 933 | unit_localization(:twelve_minute_increment, "en-CA", :short, | |
| 934 | nominative: %{ | |
| 935 | one: "{0} twelve mins", | |
| 936 | other: "{0} twelve mins" | |
| 937 | }, | |
| 938 | display_name: "12 mins" | |
| 939 | ) | |
| 940 | ||
| 941 | unit_localization(:twelve_minute_increment, "en-CA", :narrow, | |
| 942 | nominative: %{ | |
| 943 | one: "{0} twelve min", | |
| 944 | other: "{0} twelve min" | |
| 945 | }, | |
| 946 | display_name: "12 min" | |
| 947 | ) | |
| 948 | ||
| 949 | # 15 minute increment | |
| 950 | unit_localization(:fifteen_minute_increment, "en-CA", :long, | |
| 951 | nominative: %{ | |
| 952 | one: "{0} fifteen minute increment", | |
| 953 | other: "{0} fifteen minute increments" | |
| 954 | }, | |
| 955 | display_name: "15 minute increment" | |
| 956 | ) | |
| 957 | ||
| 958 | unit_localization(:fifteen_minute_increment, "en-CA", :short, | |
| 959 | nominative: %{ | |
| 960 | one: "{0} fifteen mins", | |
| 961 | other: "{0} fifteen mins" | |
| 962 | }, | |
| 963 | display_name: "15 mins" | |
| 964 | ) | |
| 965 | ||
| 966 | unit_localization(:fifteen_minute_increment, "en-CA", :narrow, | |
| 967 | nominative: %{ | |
| 968 | one: "{0} fifteen min", | |
| 969 | other: "{0} fifteen min" | |
| 970 | }, | |
| 971 | display_name: "15 min" | |
| 972 | ) | |
| 973 | ||
| 974 | # 18 minute increment | |
| 975 | unit_localization(:eighteen_minute_increment, "en-CA", :long, | |
| 976 | nominative: %{ | |
| 977 | one: "{0} eighteen minute increment", | |
| 978 | other: "{0} eighteen minute increments" | |
| 979 | }, | |
| 980 | display_name: "18 minute increment" | |
| 981 | ) | |
| 982 | ||
| 983 | unit_localization(:eighteen_minute_increment, "en-CA", :short, | |
| 984 | nominative: %{ | |
| 985 | one: "{0} eighteen mins", | |
| 986 | other: "{0} eighteen mins" | |
| 987 | }, | |
| 988 | display_name: "18 mins" | |
| 989 | ) | |
| 990 | ||
| 991 | unit_localization(:eighteen_minute_increment, "en-CA", :narrow, | |
| 992 | nominative: %{ | |
| 993 | one: "{0} eighteen min", | |
| 994 | other: "{0} eighteen min" | |
| 995 | }, | |
| 996 | display_name: "18 min" | |
| 997 | ) | |
| 998 | ||
| 999 | # 20 minute increment | |
| 1000 | unit_localization(:twenty_minute_increment, "en-CA", :long, | |
| 1001 | nominative: %{ | |
| 1002 | one: "{0} twenty minute increment", | |
| 1003 | other: "{0} twenty minute increments" | |
| 1004 | }, | |
| 1005 | display_name: "20 minute increment" | |
| 1006 | ) | |
| 1007 | ||
| 1008 | unit_localization(:twenty_minute_increment, "en-CA", :short, | |
| 1009 | nominative: %{ | |
| 1010 | one: "{0} twenty mins", | |
| 1011 | other: "{0} twenty mins" | |
| 1012 | }, | |
| 1013 | display_name: "20 mins" | |
| 1014 | ) | |
| 1015 | ||
| 1016 | unit_localization(:twenty_minute_increment, "en-CA", :narrow, | |
| 1017 | nominative: %{ | |
| 1018 | one: "{0} twenty min", | |
| 1019 | other: "{0} twenty min" | |
| 1020 | }, | |
| 1021 | display_name: "20 min" | |
| 1022 | ) | |
| 1023 | ||
| 1024 | # 24 minute increment | |
| 1025 | unit_localization(:twenty_four_minute_increment, "en-CA", :long, | |
| 1026 | nominative: %{ | |
| 1027 | one: "{0} twenty-four minute increment", | |
| 1028 | other: "{0} twenty-four minute increments" | |
| 1029 | }, | |
| 1030 | display_name: "24 minute increment" | |
| 1031 | ) | |
| 1032 | ||
| 1033 | unit_localization(:twenty_four_minute_increment, "en-CA", :short, | |
| 1034 | nominative: %{ | |
| 1035 | one: "{0} twenty-four mins", | |
| 1036 | other: "{0} twenty-four mins" | |
| 1037 | }, | |
| 1038 | display_name: "24 mins" | |
| 1039 | ) | |
| 1040 | ||
| 1041 | unit_localization(:twenty_four_minute_increment, "en-CA", :narrow, | |
| 1042 | nominative: %{ | |
| 1043 | one: "{0} twenty-four min", | |
| 1044 | other: "{0} twenty-four min" | |
| 1045 | }, | |
| 1046 | display_name: "24 min" | |
| 1047 | ) | |
| 1048 | ||
| 1049 | # 30 minute increment | |
| 1050 | unit_localization(:thirty_minute_increment, "en-CA", :long, | |
| 1051 | nominative: %{ | |
| 1052 | one: "{0} thirty minute increment", | |
| 1053 | other: "{0} thirty minute increments" | |
| 1054 | }, | |
| 1055 | display_name: "30 minute increment" | |
| 1056 | ) | |
| 1057 | ||
| 1058 | unit_localization(:thirty_minute_increment, "en-CA", :short, | |
| 1059 | nominative: %{ | |
| 1060 | one: "{0} thirty mins", | |
| 1061 | other: "{0} thirty mins" | |
| 1062 | }, | |
| 1063 | display_name: "30 mins" | |
| 1064 | ) | |
| 1065 | ||
| 1066 | unit_localization(:thirty_minute_increment, "en-CA", :narrow, | |
| 1067 | nominative: %{ | |
| 1068 | one: "{0} thirty min", | |
| 1069 | other: "{0} thirty min" | |
| 1070 | }, | |
| 1071 | display_name: "30 min" | |
| 1072 | ) | |
| 1073 | ||
| 1074 | # 36 minute increment | |
| 1075 | unit_localization(:thirty_six_minute_increment, "en-CA", :long, | |
| 1076 | nominative: %{ | |
| 1077 | one: "{0} thirty-six minute increment", | |
| 1078 | other: "{0} thirty-six minute increments" | |
| 1079 | }, | |
| 1080 | display_name: "36 minute increment" | |
| 1081 | ) | |
| 1082 | ||
| 1083 | unit_localization(:thirty_six_minute_increment, "en-CA", :short, | |
| 1084 | nominative: %{ | |
| 1085 | one: "{0} thirty-six mins", | |
| 1086 | other: "{0} thirty-six mins" | |
| 1087 | }, | |
| 1088 | display_name: "36 mins" | |
| 1089 | ) | |
| 1090 | ||
| 1091 | unit_localization(:thirty_six_minute_increment, "en-CA", :narrow, | |
| 1092 | nominative: %{ | |
| 1093 | one: "{0} thirty-six min", | |
| 1094 | other: "{0} thirty-six min" | |
| 1095 | }, | |
| 1096 | display_name: "36 min" | |
| 1097 | ) | |
| 1098 | ||
| 1099 | # 45 minute increment | |
| 1100 | unit_localization(:fourty_five_minute_increment, "en-CA", :long, | |
| 1101 | nominative: %{ | |
| 1102 | one: "{0} fourty-five minute increment", | |
| 1103 | other: "{0} fourty-five minute increments" | |
| 1104 | }, | |
| 1105 | display_name: "45 minute increment" | |
| 1106 | ) | |
| 1107 | ||
| 1108 | unit_localization(:fourty_five_minute_increment, "en-CA", :short, | |
| 1109 | nominative: %{ | |
| 1110 | one: "{0} fourty-five mins", | |
| 1111 | other: "{0} fourty-five mins" | |
| 1112 | }, | |
| 1113 | display_name: "45 mins" | |
| 1114 | ) | |
| 1115 | ||
| 1116 | unit_localization(:fourty_five_minute_increment, "en-CA", :narrow, | |
| 1117 | nominative: %{ | |
| 1118 | one: "{0} fourty-five min", | |
| 1119 | other: "{0} fourty-five min" | |
| 1120 | }, | |
| 1121 | display_name: "45 min" | |
| 1122 | ) | |
| 1123 | ||
| 1124 | # 60 minute increment | |
| 1125 | unit_localization(:sixty_minute_increment, "en-CA", :long, | |
| 1126 | nominative: %{ | |
| 1127 | one: "{0} hour", | |
| 1128 | other: "{0} hours" | |
| 1129 | }, | |
| 1130 | display_name: "60 minute increment" | |
| 1131 | ) | |
| 1132 | ||
| 1133 | unit_localization(:sixty_minute_increment, "en-CA", :short, | |
| 1134 | nominative: %{ | |
| 1135 | one: "{0} sixty mins", | |
| 1136 | other: "{0} sixty mins" | |
| 1137 | }, | |
| 1138 | display_name: "60 mins" | |
| 1139 | ) | |
| 1140 | ||
| 1141 | unit_localization(:sixty_minute_increment, "en-CA", :narrow, | |
| 1142 | nominative: %{ | |
| 1143 | one: "{0} sixty min", | |
| 1144 | other: "{0} sixty min" | |
| 1145 | }, | |
| 1146 | display_name: "60 min" | |
| 1147 | ) | |
| 1148 | ||
| 1149 | # hour increment | |
| 1150 | unit_localization(:hour_increment, "en-CA", :long, | |
| 1151 | nominative: %{ | |
| 1152 | one: "{0} hour", | |
| 1153 | other: "{0} hours" | |
| 1154 | }, | |
| 1155 | display_name: "hour" | |
| 1156 | ) | |
| 1157 | ||
| 1158 | unit_localization(:hour_increment, "en-CA", :short, | |
| 1159 | nominative: %{ | |
| 1160 | one: "{0} hour", | |
| 1161 | other: "{0} hours" | |
| 1162 | }, | |
| 1163 | display_name: "hour" | |
| 1164 | ) | |
| 1165 | ||
| 1166 | unit_localization(:hour_increment, "en-CA", :narrow, | |
| 1167 | nominative: %{ | |
| 1168 | one: "{0} hr", | |
| 1169 | other: "{0} hrs" | |
| 1170 | }, | |
| 1171 | display_name: "hour" | |
| 1172 | ) | |
| 1173 | ||
| 1174 | # 90 minute increment | |
| 1175 | unit_localization(:ninety_minute_increment, "en-CA", :long, | |
| 1176 | nominative: %{ | |
| 1177 | one: "{0} ninety minute increment", | |
| 1178 | other: "{0} ninety minute increments" | |
| 1179 | }, | |
| 1180 | display_name: "90 minute increment" | |
| 1181 | ) | |
| 1182 | ||
| 1183 | unit_localization(:ninety_minute_increment, "en-CA", :short, | |
| 1184 | nominative: %{ | |
| 1185 | one: "{0} ninety mins", | |
| 1186 | other: "{0} ninety mins" | |
| 1187 | }, | |
| 1188 | display_name: "90 mins" | |
| 1189 | ) | |
| 1190 | ||
| 1191 | unit_localization(:ninety_minute_increment, "en-CA", :narrow, | |
| 1192 | nominative: %{ | |
| 1193 | one: "{0} ninety min", | |
| 1194 | other: "{0} ninety min" | |
| 1195 | }, | |
| 1196 | display_name: "90 min" | |
| 1197 | ) | |
| 1198 | ||
| 1199 | # 120 minute increment | |
| 1200 | unit_localization(:one_hundred_twenty_minute_increment, "en-CA", :long, | |
| 1201 | nominative: %{ | |
| 1202 | one: "{0} one hundred twenty minute increment", | |
| 1203 | other: "{0} one hundred twenty minute increments" | |
| 1204 | }, | |
| 1205 | display_name: "2 hour increment" | |
| 1206 | ) | |
| 1207 | ||
| 1208 | unit_localization(:one_hundred_twenty_minute_increment, "en-CA", :short, | |
| 1209 | nominative: %{ | |
| 1210 | one: "{0} one-twenty mins", | |
| 1211 | other: "{0} one-twenty mins" | |
| 1212 | }, | |
| 1213 | display_name: "2 hours" | |
| 1214 | ) | |
| 1215 | ||
| 1216 | unit_localization(:one_hundred_twenty_minute_increment, "en-CA", :narrow, | |
| 1217 | nominative: %{ | |
| 1218 | one: "{0} one-twenty min", | |
| 1219 | other: "{0} one-twenty min" | |
| 1220 | }, | |
| 1221 | display_name: "2 hour increment" | |
| 1222 | ) | |
| 1223 | ||
| 1224 | # "en-GB" locale | |
| 1225 | # minute increment | |
| 1226 | unit_localization(:minute_increment, "en-GB", :long, | |
| 1227 | nominative: %{ | |
| 1228 | one: "{0} minute", | |
| 1229 | other: "{0} minutes" | |
| 1230 | }, | |
| 1231 | display_name: "Minutes" | |
| 1232 | ) | |
| 1233 | ||
| 1234 | unit_localization(:minute_increment, "en-GB", :short, | |
| 1235 | nominative: %{ | |
| 1236 | one: "{0} min", | |
| 1237 | other: "{0} mins" | |
| 1238 | }, | |
| 1239 | display_name: "Minutes" | |
| 1240 | ) | |
| 1241 | ||
| 1242 | unit_localization(:minute_increment, "en-GB", :narrow, | |
| 1243 | nominative: %{ | |
| 1244 | one: "{0} min", | |
| 1245 | other: "{0} mins" | |
| 1246 | }, | |
| 1247 | display_name: "Minutes" | |
| 1248 | ) | |
| 1249 | ||
| 1250 | # 5 minute increment | |
| 1251 | unit_localization(:five_minute_increment, "en-GB", :long, | |
| 1252 | nominative: %{ | |
| 1253 | one: "{0} five minute increment", | |
| 1254 | other: "{0} five minute increments" | |
| 1255 | }, | |
| 1256 | display_name: "5 minutes" | |
| 1257 | ) | |
| 1258 | ||
| 1259 | unit_localization(:five_minute_increment, "en-GB", :short, | |
| 1260 | nominative: %{ | |
| 1261 | one: "{0} five mins", | |
| 1262 | other: "{0} five mins" | |
| 1263 | }, | |
| 1264 | display_name: "5 mins" | |
| 1265 | ) | |
| 1266 | ||
| 1267 | unit_localization(:five_minute_increment, "en-GB", :narrow, | |
| 1268 | nominative: %{ | |
| 1269 | one: "{0} five min", | |
| 1270 | other: "{0} five min" | |
| 1271 | }, | |
| 1272 | display_name: "5 min" | |
| 1273 | ) | |
| 1274 | ||
| 1275 | # 6 minute increment | |
| 1276 | unit_localization(:six_minute_increment, "en-GB", :long, | |
| 1277 | nominative: %{ | |
| 1278 | one: "{0} six minute increment", | |
| 1279 | other: "{0} six minute increments" | |
| 1280 | }, | |
| 1281 | display_name: "6 minutes" | |
| 1282 | ) | |
| 1283 | ||
| 1284 | unit_localization(:six_minute_increment, "en-GB", :short, | |
| 1285 | nominative: %{ | |
| 1286 | one: "{0} six mins", | |
| 1287 | other: "{0} six mins" | |
| 1288 | }, | |
| 1289 | display_name: "6 mins" | |
| 1290 | ) | |
| 1291 | ||
| 1292 | unit_localization(:six_minute_increment, "en-GB", :narrow, | |
| 1293 | nominative: %{ | |
| 1294 | one: "{0} six min", | |
| 1295 | other: "{0} six min" | |
| 1296 | }, | |
| 1297 | display_name: "6 min" | |
| 1298 | ) | |
| 1299 | ||
| 1300 | # 10 minute increment | |
| 1301 | unit_localization(:ten_minute_increment, "en-GB", :long, | |
| 1302 | nominative: %{ | |
| 1303 | one: "{0} ten minute increment", | |
| 1304 | other: "{0} ten minute increments" | |
| 1305 | }, | |
| 1306 | display_name: "10 minute increment" | |
| 1307 | ) | |
| 1308 | ||
| 1309 | unit_localization(:ten_minute_increment, "en-GB", :short, | |
| 1310 | nominative: %{ | |
| 1311 | one: "{0} ten mins", | |
| 1312 | other: "{0} ten mins" | |
| 1313 | }, | |
| 1314 | display_name: "10 mins" | |
| 1315 | ) | |
| 1316 | ||
| 1317 | unit_localization(:ten_minute_increment, "en-GB", :narrow, | |
| 1318 | nominative: %{ | |
| 1319 | one: "{0} ten min", | |
| 1320 | other: "{0} ten min" | |
| 1321 | }, | |
| 1322 | display_name: "10 min" | |
| 1323 | ) | |
| 1324 | ||
| 1325 | # 12 minute increment | |
| 1326 | unit_localization(:twelve_minute_increment, "en-GB", :long, | |
| 1327 | nominative: %{ | |
| 1328 | one: "{0} twelve minute increment", | |
| 1329 | other: "{0} twelve minute increments" | |
| 1330 | }, | |
| 1331 | display_name: "12 minute increment" | |
| 1332 | ) | |
| 1333 | ||
| 1334 | unit_localization(:twelve_minute_increment, "en-GB", :short, | |
| 1335 | nominative: %{ | |
| 1336 | one: "{0} twelve mins", | |
| 1337 | other: "{0} twelve mins" | |
| 1338 | }, | |
| 1339 | display_name: "12 mins" | |
| 1340 | ) | |
| 1341 | ||
| 1342 | unit_localization(:twelve_minute_increment, "en-GB", :narrow, | |
| 1343 | nominative: %{ | |
| 1344 | one: "{0} twelve min", | |
| 1345 | other: "{0} twelve min" | |
| 1346 | }, | |
| 1347 | display_name: "12 min" | |
| 1348 | ) | |
| 1349 | ||
| 1350 | # 15 minute increment | |
| 1351 | unit_localization(:fifteen_minute_increment, "en-GB", :long, | |
| 1352 | nominative: %{ | |
| 1353 | one: "{0} fifteen minute increment", | |
| 1354 | other: "{0} fifteen minute increments" | |
| 1355 | }, | |
| 1356 | display_name: "15 minute increment" | |
| 1357 | ) | |
| 1358 | ||
| 1359 | unit_localization(:fifteen_minute_increment, "en-GB", :short, | |
| 1360 | nominative: %{ | |
| 1361 | one: "{0} fifteen mins", | |
| 1362 | other: "{0} fifteen mins" | |
| 1363 | }, | |
| 1364 | display_name: "15 mins" | |
| 1365 | ) | |
| 1366 | ||
| 1367 | unit_localization(:fifteen_minute_increment, "en-GB", :narrow, | |
| 1368 | nominative: %{ | |
| 1369 | one: "{0} fifteen min", | |
| 1370 | other: "{0} fifteen min" | |
| 1371 | }, | |
| 1372 | display_name: "15 min" | |
| 1373 | ) | |
| 1374 | ||
| 1375 | # 18 minute increment | |
| 1376 | unit_localization(:eighteen_minute_increment, "en-GB", :long, | |
| 1377 | nominative: %{ | |
| 1378 | one: "{0} eighteen minute increment", | |
| 1379 | other: "{0} eighteen minute increments" | |
| 1380 | }, | |
| 1381 | display_name: "18 minute increment" | |
| 1382 | ) | |
| 1383 | ||
| 1384 | unit_localization(:eighteen_minute_increment, "en-GB", :short, | |
| 1385 | nominative: %{ | |
| 1386 | one: "{0} eighteen mins", | |
| 1387 | other: "{0} eighteen mins" | |
| 1388 | }, | |
| 1389 | display_name: "18 mins" | |
| 1390 | ) | |
| 1391 | ||
| 1392 | unit_localization(:eighteen_minute_increment, "en-GB", :narrow, | |
| 1393 | nominative: %{ | |
| 1394 | one: "{0} eighteen min", | |
| 1395 | other: "{0} eighteen min" | |
| 1396 | }, | |
| 1397 | display_name: "18 min" | |
| 1398 | ) | |
| 1399 | ||
| 1400 | # 20 minute increment | |
| 1401 | unit_localization(:twenty_minute_increment, "en-GB", :long, | |
| 1402 | nominative: %{ | |
| 1403 | one: "{0} twenty minute increment", | |
| 1404 | other: "{0} twenty minute increments" | |
| 1405 | }, | |
| 1406 | display_name: "20 minute increment" | |
| 1407 | ) | |
| 1408 | ||
| 1409 | unit_localization(:twenty_minute_increment, "en-GB", :short, | |
| 1410 | nominative: %{ | |
| 1411 | one: "{0} twenty mins", | |
| 1412 | other: "{0} twenty mins" | |
| 1413 | }, | |
| 1414 | display_name: "20 mins" | |
| 1415 | ) | |
| 1416 | ||
| 1417 | unit_localization(:twenty_minute_increment, "en-GB", :narrow, | |
| 1418 | nominative: %{ | |
| 1419 | one: "{0} twenty min", | |
| 1420 | other: "{0} twenty min" | |
| 1421 | }, | |
| 1422 | display_name: "20 min" | |
| 1423 | ) | |
| 1424 | ||
| 1425 | # 24 minute increment | |
| 1426 | unit_localization(:twenty_four_minute_increment, "en-GB", :long, | |
| 1427 | nominative: %{ | |
| 1428 | one: "{0} twenty-four minute increment", | |
| 1429 | other: "{0} twenty-four minute increments" | |
| 1430 | }, | |
| 1431 | display_name: "24 minute increment" | |
| 1432 | ) | |
| 1433 | ||
| 1434 | unit_localization(:twenty_four_minute_increment, "en-GB", :short, | |
| 1435 | nominative: %{ | |
| 1436 | one: "{0} twenty-four mins", | |
| 1437 | other: "{0} twenty-four mins" | |
| 1438 | }, | |
| 1439 | display_name: "24 mins" | |
| 1440 | ) | |
| 1441 | ||
| 1442 | unit_localization(:twenty_four_minute_increment, "en-GB", :narrow, | |
| 1443 | nominative: %{ | |
| 1444 | one: "{0} twenty-four min", | |
| 1445 | other: "{0} twenty-four min" | |
| 1446 | }, | |
| 1447 | display_name: "24 min" | |
| 1448 | ) | |
| 1449 | ||
| 1450 | # 30 minute increment | |
| 1451 | unit_localization(:thirty_minute_increment, "en-GB", :long, | |
| 1452 | nominative: %{ | |
| 1453 | one: "{0} thirty minute increment", | |
| 1454 | other: "{0} thirty minute increments" | |
| 1455 | }, | |
| 1456 | display_name: "30 minute increment" | |
| 1457 | ) | |
| 1458 | ||
| 1459 | unit_localization(:thirty_minute_increment, "en-GB", :short, | |
| 1460 | nominative: %{ | |
| 1461 | one: "{0} thirty mins", | |
| 1462 | other: "{0} thirty mins" | |
| 1463 | }, | |
| 1464 | display_name: "30 mins" | |
| 1465 | ) | |
| 1466 | ||
| 1467 | unit_localization(:thirty_minute_increment, "en-GB", :narrow, | |
| 1468 | nominative: %{ | |
| 1469 | one: "{0} thirty min", | |
| 1470 | other: "{0} thirty min" | |
| 1471 | }, | |
| 1472 | display_name: "30 min" | |
| 1473 | ) | |
| 1474 | ||
| 1475 | # 36 minute increment | |
| 1476 | unit_localization(:thirty_six_minute_increment, "en-GB", :long, | |
| 1477 | nominative: %{ | |
| 1478 | one: "{0} thirty-six minute increment", | |
| 1479 | other: "{0} thirty-six minute increments" | |
| 1480 | }, | |
| 1481 | display_name: "36 minute increment" | |
| 1482 | ) | |
| 1483 | ||
| 1484 | unit_localization(:thirty_six_minute_increment, "en-GB", :short, | |
| 1485 | nominative: %{ | |
| 1486 | one: "{0} thirty-six mins", | |
| 1487 | other: "{0} thirty-six mins" | |
| 1488 | }, | |
| 1489 | display_name: "36 mins" | |
| 1490 | ) | |
| 1491 | ||
| 1492 | unit_localization(:thirty_six_minute_increment, "en-GB", :narrow, | |
| 1493 | nominative: %{ | |
| 1494 | one: "{0} thirty-six min", | |
| 1495 | other: "{0} thirty-six min" | |
| 1496 | }, | |
| 1497 | display_name: "36 min" | |
| 1498 | ) | |
| 1499 | ||
| 1500 | # 45 minute increment | |
| 1501 | unit_localization(:fourty_five_minute_increment, "en-GB", :long, | |
| 1502 | nominative: %{ | |
| 1503 | one: "{0} fourty-five minute increment", | |
| 1504 | other: "{0} fourty-five minute increments" | |
| 1505 | }, | |
| 1506 | display_name: "45 minute increment" | |
| 1507 | ) | |
| 1508 | ||
| 1509 | unit_localization(:fourty_five_minute_increment, "en-GB", :short, | |
| 1510 | nominative: %{ | |
| 1511 | one: "{0} fourty-five mins", | |
| 1512 | other: "{0} fourty-five mins" | |
| 1513 | }, | |
| 1514 | display_name: "45 mins" | |
| 1515 | ) | |
| 1516 | ||
| 1517 | unit_localization(:fourty_five_minute_increment, "en-GB", :narrow, | |
| 1518 | nominative: %{ | |
| 1519 | one: "{0} fourty-five min", | |
| 1520 | other: "{0} fourty-five min" | |
| 1521 | }, | |
| 1522 | display_name: "45 min" | |
| 1523 | ) | |
| 1524 | ||
| 1525 | # 60 minute increment | |
| 1526 | unit_localization(:sixty_minute_increment, "en-GB", :long, | |
| 1527 | nominative: %{ | |
| 1528 | one: "{0} hour", | |
| 1529 | other: "{0} hours" | |
| 1530 | }, | |
| 1531 | display_name: "60 minute increment" | |
| 1532 | ) | |
| 1533 | ||
| 1534 | unit_localization(:sixty_minute_increment, "en-GB", :short, | |
| 1535 | nominative: %{ | |
| 1536 | one: "{0} sixty mins", | |
| 1537 | other: "{0} sixty mins" | |
| 1538 | }, | |
| 1539 | display_name: "60 mins" | |
| 1540 | ) | |
| 1541 | ||
| 1542 | unit_localization(:sixty_minute_increment, "en-GB", :narrow, | |
| 1543 | nominative: %{ | |
| 1544 | one: "{0} sixty min", | |
| 1545 | other: "{0} sixty min" | |
| 1546 | }, | |
| 1547 | display_name: "60 min" | |
| 1548 | ) | |
| 1549 | ||
| 1550 | # hour increment | |
| 1551 | unit_localization(:hour_increment, "en-GB", :long, | |
| 1552 | nominative: %{ | |
| 1553 | one: "{0} hour", | |
| 1554 | other: "{0} hours" | |
| 1555 | }, | |
| 1556 | display_name: "hour" | |
| 1557 | ) | |
| 1558 | ||
| 1559 | unit_localization(:hour_increment, "en-GB", :short, | |
| 1560 | nominative: %{ | |
| 1561 | one: "{0} hour", | |
| 1562 | other: "{0} hours" | |
| 1563 | }, | |
| 1564 | display_name: "hour" | |
| 1565 | ) | |
| 1566 | ||
| 1567 | unit_localization(:hour_increment, "en-GB", :narrow, | |
| 1568 | nominative: %{ | |
| 1569 | one: "{0} hr", | |
| 1570 | other: "{0} hrs" | |
| 1571 | }, | |
| 1572 | display_name: "hour" | |
| 1573 | ) | |
| 1574 | ||
| 1575 | # 90 minute increment | |
| 1576 | unit_localization(:ninety_minute_increment, "en-GB", :long, | |
| 1577 | nominative: %{ | |
| 1578 | one: "{0} ninety minute increment", | |
| 1579 | other: "{0} ninety minute increments" | |
| 1580 | }, | |
| 1581 | display_name: "90 minute increment" | |
| 1582 | ) | |
| 1583 | ||
| 1584 | unit_localization(:ninety_minute_increment, "en-GB", :short, | |
| 1585 | nominative: %{ | |
| 1586 | one: "{0} ninety mins", | |
| 1587 | other: "{0} ninety mins" | |
| 1588 | }, | |
| 1589 | display_name: "90 mins" | |
| 1590 | ) | |
| 1591 | ||
| 1592 | unit_localization(:ninety_minute_increment, "en-GB", :narrow, | |
| 1593 | nominative: %{ | |
| 1594 | one: "{0} ninety min", | |
| 1595 | other: "{0} ninety min" | |
| 1596 | }, | |
| 1597 | display_name: "90 min" | |
| 1598 | ) | |
| 1599 | ||
| 1600 | # 120 minute increment | |
| 1601 | unit_localization(:one_hundred_twenty_minute_increment, "en-GB", :long, | |
| 1602 | nominative: %{ | |
| 1603 | one: "{0} one hundred twenty minute increment", | |
| 1604 | other: "{0} one hundred twenty minute increments" | |
| 1605 | }, | |
| 1606 | display_name: "2 hour increment" | |
| 1607 | ) | |
| 1608 | ||
| 1609 | unit_localization(:one_hundred_twenty_minute_increment, "en-GB", :short, | |
| 1610 | nominative: %{ | |
| 1611 | one: "{0} one-twenty mins", | |
| 1612 | other: "{0} one-twenty mins" | |
| 1613 | }, | |
| 1614 | display_name: "2 hours" | |
| 1615 | ) | |
| 1616 | ||
| 1617 | unit_localization(:one_hundred_twenty_minute_increment, "en-GB", :narrow, | |
| 1618 | nominative: %{ | |
| 1619 | one: "{0} one-twenty min", | |
| 1620 | other: "{0} one-twenty min" | |
| 1621 | }, | |
| 1622 | display_name: "2 hour increment" | |
| 1623 | ) | |
| 1624 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Journals do | |
| 1 | @moduledoc """ | |
| 2 | The Journals context. | |
| 3 | """ | |
| 4 | ||
| 5 | # import Ecto.Query, warn: false | |
| 6 | import Ecto.Query | |
| 7 | alias Klepsidra.Repo | |
| 8 | ||
| 9 | alias Klepsidra.Journals.JournalEntry | |
| 10 | ||
| 11 | @doc """ | |
| 12 | Returns the list of journal_entries. | |
| 13 | ||
| 14 | ## Examples | |
| 15 | ||
| 16 | iex> list_journal_entries() | |
| 17 | [%JournalEntry{}, ...] | |
| 18 | ||
| 19 | """ | |
| 20 | @spec list_journal_entries() :: [JournalEntry.t(), ...] | |
| 21 | def list_journal_entries do | |
| 22 | 0 | JournalEntry |> order_by(asc: :journal_for, asc: :inserted_at) |> Repo.all() |
| 23 | end | |
| 24 | ||
| 25 | @doc """ | |
| 26 | Given a result of a `journal_entries` query, additionally preload the | |
| 27 | `entry_type` association. | |
| 28 | ||
| 29 | ## Examples | |
| 30 | ||
| 31 | iex> list_journal_entries() |> preload_journal_entry_type() | |
| 32 | [%JournalEntry{%Klepsidra.Journals.JournalEntryTypes{}}, ...] | |
| 33 | ||
| 34 | """ | |
| 35 | @spec preload_journal_entry_type(journal_entries :: [JournalEntry.t(), ...]) :: | |
| 36 | [JournalEntry.t(), ...] | |
| 37 | def preload_journal_entry_type(journal_entries) when is_list(journal_entries) do | |
| 38 | 0 | Repo.preload(journal_entries, :entry_type) |
| 39 | end | |
| 40 | ||
| 41 | @doc """ | |
| 42 | Gets a single journal_entry. | |
| 43 | ||
| 44 | Raises `Ecto.NoResultsError` if the Journal entry does not exist. | |
| 45 | ||
| 46 | ## Examples | |
| 47 | ||
| 48 | iex> get_journal_entry!(123) | |
| 49 | %JournalEntry{} | |
| 50 | ||
| 51 | iex> get_journal_entry!(456) | |
| 52 | ** (Ecto.NoResultsError) | |
| 53 | ||
| 54 | """ | |
| 55 | @spec get_journal_entry!(id :: Ecto.UUID.t()) :: | |
| 56 | JournalEntry.t() | |
| 57 | 0 | def get_journal_entry!(id), do: Repo.get!(JournalEntry, id) |
| 58 | ||
| 59 | @doc """ | |
| 60 | Creates a journal_entry. | |
| 61 | ||
| 62 | ## Examples | |
| 63 | ||
| 64 | iex> create_journal_entry(%{field: value}) | |
| 65 | {:ok, %JournalEntry{}} | |
| 66 | ||
| 67 | iex> create_journal_entry(%{field: bad_value}) | |
| 68 | {:error, Ecto.Changeset.t()} | |
| 69 | ||
| 70 | """ | |
| 71 | @spec create_journal_entry(attrs :: map()) :: | |
| 72 | {:ok, JournalEntry.t()} | {:error, Ecto.Changeset.t()} | |
| 73 | def create_journal_entry(attrs \\ %{}) do | |
| 74 | %JournalEntry{} | |
| 75 | |> JournalEntry.changeset(attrs) | |
| 76 | 0 | |> Repo.insert() |
| 77 | end | |
| 78 | ||
| 79 | @doc """ | |
| 80 | Updates a journal_entry. | |
| 81 | ||
| 82 | ## Examples | |
| 83 | ||
| 84 | iex> update_journal_entry(journal_entry, %{field: new_value}) | |
| 85 | {:ok, %JournalEntry{}} | |
| 86 | ||
| 87 | iex> update_journal_entry(journal_entry, %{field: bad_value}) | |
| 88 | {:error, Ecto.Changeset.t()} | |
| 89 | ||
| 90 | """ | |
| 91 | @spec update_journal_entry( | |
| 92 | journal_entry :: JournalEntry.t(), | |
| 93 | attrs :: map() | |
| 94 | ) :: | |
| 95 | {:ok, JournalEntry.t()} | {:error, Ecto.Changeset.t()} | |
| 96 | def update_journal_entry(%JournalEntry{} = journal_entry, attrs) do | |
| 97 | journal_entry | |
| 98 | |> JournalEntry.changeset(attrs) | |
| 99 | 0 | |> Repo.update() |
| 100 | end | |
| 101 | ||
| 102 | @doc """ | |
| 103 | Deletes a journal_entry. | |
| 104 | ||
| 105 | ## Examples | |
| 106 | ||
| 107 | iex> delete_journal_entry(journal_entry) | |
| 108 | {:ok, %JournalEntry{}} | |
| 109 | ||
| 110 | iex> delete_journal_entry(journal_entry) | |
| 111 | {:error, Ecto.Changeset.t()} | |
| 112 | ||
| 113 | """ | |
| 114 | @spec delete_journal_entry(journal_entry :: JournalEntry.t()) :: | |
| 115 | {:ok, JournalEntry.t()} | {:error, Ecto.Changeset.t()} | |
| 116 | def delete_journal_entry(%JournalEntry{} = journal_entry) do | |
| 117 | 0 | Repo.delete(journal_entry) |
| 118 | end | |
| 119 | ||
| 120 | @doc """ | |
| 121 | Returns an `Ecto.Changeset.t()` for tracking journal_entry changes. | |
| 122 | ||
| 123 | ## Examples | |
| 124 | ||
| 125 | iex> change_journal_entry(journal_entry) | |
| 126 | %Ecto.Changeset{data: %JournalEntry{}} | |
| 127 | ||
| 128 | """ | |
| 129 | @spec change_journal_entry(journal_entry :: JournalEntry.t(), attrs :: map()) :: | |
| 130 | Ecto.Changeset.t() | |
| 131 | def change_journal_entry(%JournalEntry{} = journal_entry, attrs \\ %{}) do | |
| 132 | 0 | JournalEntry.changeset(journal_entry, attrs) |
| 133 | end | |
| 134 | ||
| 135 | alias Klepsidra.Journals.JournalEntryTypes | |
| 136 | ||
| 137 | @doc """ | |
| 138 | Returns the list of journal_entry_types. | |
| 139 | ||
| 140 | ## Examples | |
| 141 | ||
| 142 | iex> list_journal_entry_types() | |
| 143 | [%JournalEntryTypes{}, ...] | |
| 144 | ||
| 145 | """ | |
| 146 | @spec list_journal_entries() :: [JournalEntryTypes.t(), ...] | |
| 147 | def list_journal_entry_types do | |
| 148 | 5 | Repo.all(JournalEntryTypes) |
| 149 | end | |
| 150 | ||
| 151 | @doc """ | |
| 152 | Gets a single journal_entry_types. | |
| 153 | ||
| 154 | Raises `Ecto.NoResultsError` if the Journal entry types does not exist. | |
| 155 | ||
| 156 | ## Examples | |
| 157 | ||
| 158 | iex> get_journal_entry_types!(123) | |
| 159 | %JournalEntryTypes{} | |
| 160 | ||
| 161 | iex> get_journal_entry_types!(456) | |
| 162 | ** (Ecto.NoResultsError) | |
| 163 | ||
| 164 | """ | |
| 165 | @spec get_journal_entry_types!(id :: Ecto.UUID.t()) :: JournalEntryTypes.t() | no_return() | |
| 166 | 8 | def get_journal_entry_types!(id), do: Repo.get!(JournalEntryTypes, id) |
| 167 | ||
| 168 | @doc """ | |
| 169 | Creates a journal_entry_types. | |
| 170 | ||
| 171 | ## Examples | |
| 172 | ||
| 173 | iex> create_journal_entry_types(%{field: value}) | |
| 174 | {:ok, %JournalEntryTypes{}} | |
| 175 | ||
| 176 | iex> create_journal_entry_types(%{field: bad_value}) | |
| 177 | {:error, Ecto.Changeset.t()} | |
| 178 | ||
| 179 | """ | |
| 180 | @spec create_journal_entry_types(attrs :: map()) :: | |
| 181 | {:ok, JournalEntryTypes.t()} | {:error, Ecto.Changeset.t()} | |
| 182 | def create_journal_entry_types(attrs \\ %{}) do | |
| 183 | %JournalEntryTypes{} | |
| 184 | |> JournalEntryTypes.changeset(attrs) | |
| 185 | 12 | |> Repo.insert() |
| 186 | end | |
| 187 | ||
| 188 | @doc """ | |
| 189 | Updates a journal_entry_types. | |
| 190 | ||
| 191 | ## Examples | |
| 192 | ||
| 193 | iex> update_journal_entry_types(journal_entry_types, %{field: new_value}) | |
| 194 | {:ok, %JournalEntryTypes{}} | |
| 195 | ||
| 196 | iex> update_journal_entry_types(journal_entry_types, %{field: bad_value}) | |
| 197 | {:error, Ecto.Changeset.t()} | |
| 198 | ||
| 199 | """ | |
| 200 | @spec update_journal_entry_types( | |
| 201 | journal_entry_types :: JournalEntryTypes.t(), | |
| 202 | attrs :: map() | |
| 203 | ) :: | |
| 204 | {:ok, JournalEntryTypes.t()} | {:error, Ecto.Changeset.t()} | |
| 205 | def update_journal_entry_types(%JournalEntryTypes{} = journal_entry_types, attrs) do | |
| 206 | journal_entry_types | |
| 207 | |> JournalEntryTypes.changeset(attrs) | |
| 208 | 2 | |> Repo.update() |
| 209 | end | |
| 210 | ||
| 211 | @doc """ | |
| 212 | Deletes a journal_entry_types. | |
| 213 | ||
| 214 | ## Examples | |
| 215 | ||
| 216 | iex> delete_journal_entry_types(journal_entry_types) | |
| 217 | {:ok, %JournalEntryTypes{}} | |
| 218 | ||
| 219 | iex> delete_journal_entry_types(journal_entry_types) | |
| 220 | {:error, Ecto.Changeset.t()} | |
| 221 | ||
| 222 | """ | |
| 223 | @spec delete_journal_entry_types(journal_entry_types :: JournalEntryTypes.t()) :: | |
| 224 | {:ok, JournalEntryTypes.t()} | {:error, Ecto.Changeset.t()} | |
| 225 | def delete_journal_entry_types(%JournalEntryTypes{} = journal_entry_types) do | |
| 226 | 1 | Repo.delete(journal_entry_types) |
| 227 | end | |
| 228 | ||
| 229 | @doc """ | |
| 230 | Returns an `Ecto.Changeset.t()` for tracking journal_entry_types changes. | |
| 231 | ||
| 232 | ## Examples | |
| 233 | ||
| 234 | iex> change_journal_entry_types(journal_entry_types) | |
| 235 | %Ecto.Changeset{data: %JournalEntryTypes{}} | |
| 236 | ||
| 237 | """ | |
| 238 | @spec change_journal_entry_types(journal_entry_types :: JournalEntryTypes.t(), attrs :: map()) :: | |
| 239 | Ecto.Changeset.t() | |
| 240 | def change_journal_entry_types(%JournalEntryTypes{} = journal_entry_types, attrs \\ %{}) do | |
| 241 | 3 | JournalEntryTypes.changeset(journal_entry_types, attrs) |
| 242 | end | |
| 243 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Journals.JournalEntry do | |
| 1 | @moduledoc """ | |
| 2 | Defines the `journal_entries` schema needed to record a generic set of journaling | |
| 3 | needs, from the deeply personal, to commercial. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 10 | @foreign_key_type Ecto.UUID | |
| 11 | ||
| 12 | @type t :: %__MODULE__{ | |
| 13 | journal_for: String.t(), | |
| 14 | entry_text_markdown: String.t(), | |
| 15 | entry_text_html: String.t(), | |
| 16 | highlights: String.t(), | |
| 17 | entry_type_id: Ecto.UUID.t(), | |
| 18 | location_id: Ecto.UUID.t(), | |
| 19 | latitude: float(), | |
| 20 | longitude: float(), | |
| 21 | mood: String.t(), | |
| 22 | is_private: boolean(), | |
| 23 | is_short_entry: boolean(), | |
| 24 | is_scheduled: boolean(), | |
| 25 | user_id: integer() | |
| 26 | } | |
| 27 | 0 | schema "journal_entries" do |
| 28 | field(:journal_for, :string) | |
| 29 | field(:entry_text_markdown, :string) | |
| 30 | field(:entry_text_html, :string) | |
| 31 | field(:highlights, :string) | |
| 32 | belongs_to(:entry_type, Klepsidra.Journals.JournalEntryTypes) | |
| 33 | belongs_to(:location, Klepsidra.Locations.City) | |
| 34 | field(:latitude, :float, default: nil) | |
| 35 | field(:longitude, :float, default: nil) | |
| 36 | field(:mood, :string, default: "") | |
| 37 | field(:is_private, :boolean, default: false) | |
| 38 | field(:is_short_entry, :boolean, default: false) | |
| 39 | field(:is_scheduled, :boolean, default: false) | |
| 40 | belongs_to(:user, Klepsidra.Accounts.User) | |
| 41 | ||
| 42 | many_to_many(:tags, Klepsidra.Categorisation.Tag, | |
| 43 | join_through: "journal_entry_tags", | |
| 44 | on_replace: :delete, | |
| 45 | preload_order: [asc: :name] | |
| 46 | ) | |
| 47 | ||
| 48 | timestamps() | |
| 49 | end | |
| 50 | ||
| 51 | @doc false | |
| 52 | def changeset(journal_entry, attrs) do | |
| 53 | journal_entry | |
| 54 | |> cast(attrs, [ | |
| 55 | :journal_for, | |
| 56 | :entry_text_markdown, | |
| 57 | :entry_text_html, | |
| 58 | :entry_type_id, | |
| 59 | :highlights, | |
| 60 | :location_id, | |
| 61 | :latitude, | |
| 62 | :longitude, | |
| 63 | :mood, | |
| 64 | :is_private, | |
| 65 | :is_short_entry, | |
| 66 | :is_scheduled, | |
| 67 | :user_id | |
| 68 | ]) | |
| 69 | |> generate_html_entry() | |
| 70 | |> validate_required(:journal_for, message: "Enter the date this journal is for") | |
| 71 | |> validate_required(:entry_text_html, message: "You must write your journal entry") | |
| 72 | |> validate_required(:entry_type_id, | |
| 73 | message: "Please select what type of journal entry you're logging" | |
| 74 | ) | |
| 75 | |> assoc_constraint(:entry_type) | |
| 76 | |> validate_required(:location_id, | |
| 77 | message: "Where are you as you log this entry?" | |
| 78 | ) | |
| 79 | 0 | |> assoc_constraint(:location) |
| 80 | end | |
| 81 | ||
| 82 | @doc """ | |
| 83 | Early in the validation chain, ensuring that validity of all necessary fields | |
| 84 | hasn't been checked yet, convert all text written in the markdown field to clean | |
| 85 | HTML. | |
| 86 | """ | |
| 87 | def generate_html_entry( | |
| 88 | %{valid?: true, changes: %{entry_text_markdown: entry_text_markdown}} = changeset | |
| 89 | ) do | |
| 90 | 0 | put_change(changeset, :entry_text_html, convert_markdown_to_html(entry_text_markdown)) |
| 91 | end | |
| 92 | ||
| 93 | 0 | def generate_html_entry(changeset), do: changeset |
| 94 | ||
| 95 | @doc """ | |
| 96 | Take in markdown-formatted text, converting it to HTML. | |
| 97 | """ | |
| 98 | def convert_markdown_to_html(markdown_string) when is_bitstring(markdown_string) do | |
| 99 | Earmark.as_html!(markdown_string, | |
| 100 | breaks: true, | |
| 101 | code_class_prefix: "lang- language-", | |
| 102 | compact_output: false, | |
| 103 | escape: false, | |
| 104 | footnotes: true, | |
| 105 | gfm_tables: true, | |
| 106 | smartypants: true, | |
| 107 | sub_sup: true | |
| 108 | ) | |
| 109 | 0 | |> HtmlSanitizeEx.html5() |
| 110 | end | |
| 111 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Journals.JournalEntryTypes do | |
| 1 | @moduledoc """ | |
| 2 | Defines the `JournalEntryTypes` schema and functions needed to categorise the | |
| 3 | type of journal entries recorded. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 10 | @foreign_key_type Ecto.UUID | |
| 11 | ||
| 12 | @type t :: %__MODULE__{ | |
| 13 | name: String.t(), | |
| 14 | description: String.t(), | |
| 15 | active: boolean() | |
| 16 | } | |
| 17 | 180 | schema "journal_entry_types" do |
| 18 | field :name, :string | |
| 19 | field :description, :string | |
| 20 | field :active, :boolean, default: true | |
| 21 | ||
| 22 | timestamps() | |
| 23 | end | |
| 24 | ||
| 25 | @doc false | |
| 26 | def changeset(journal_entry_types, attrs) do | |
| 27 | journal_entry_types | |
| 28 | |> cast(attrs, [:name, :description, :active]) | |
| 29 | |> validate_required(:name, message: "Enter a journal entry type") | |
| 30 | 17 | |> unique_constraint(:name, |
| 31 | message: "A journal entry type with this name already exists" | |
| 32 | ) | |
| 33 | end | |
| 34 | ||
| 35 | @doc """ | |
| 36 | Used across live components to populate select options with journal entry types. | |
| 37 | """ | |
| 38 | @spec populate_entry_types_list() :: [Klepsidra.Journals.JournalEntryTypes.t(), ...] | |
| 39 | 0 | def populate_entry_types_list() do |
| 40 | [ | |
| 41 | {"", ""} | |
| 42 | | Klepsidra.Journals.list_journal_entry_types() | |
| 43 | 0 | |> Enum.map(fn entry_type -> {entry_type.name, entry_type.id} end) |
| 44 | ] | |
| 45 | end | |
| 46 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Localisation do | |
| 1 | @moduledoc """ | |
| 2 | The Localisation context. | |
| 3 | """ | |
| 4 | ||
| 5 | import Ecto.Query, warn: false | |
| 6 | alias Klepsidra.Repo | |
| 7 | ||
| 8 | alias Klepsidra.Localisation.Language | |
| 9 | ||
| 10 | @doc """ | |
| 11 | Returns the list of localisation_languages. | |
| 12 | ||
| 13 | ## Examples | |
| 14 | ||
| 15 | iex> list_localisation_languages() | |
| 16 | [%Language{}, ...] | |
| 17 | ||
| 18 | """ | |
| 19 | def list_localisation_languages do | |
| 20 | 1 | Repo.all(Language) |
| 21 | end | |
| 22 | ||
| 23 | @doc """ | |
| 24 | Gets a single language. | |
| 25 | ||
| 26 | Raises `Ecto.NoResultsError` if the Language does not exist. | |
| 27 | ||
| 28 | ## Examples | |
| 29 | ||
| 30 | iex> get_language!(123) | |
| 31 | %Language{} | |
| 32 | ||
| 33 | iex> get_language!(456) | |
| 34 | ** (Ecto.NoResultsError) | |
| 35 | ||
| 36 | """ | |
| 37 | def get_language!(language_code) do | |
| 38 | 3 | Language |> where([l], l."iso_639-3_language_code" == ^language_code) |> Repo.one() |
| 39 | end | |
| 40 | ||
| 41 | @doc """ | |
| 42 | Creates a language. | |
| 43 | ||
| 44 | ## Examples | |
| 45 | ||
| 46 | iex> create_language(%{field: value}) | |
| 47 | {:ok, %Language{}} | |
| 48 | ||
| 49 | iex> create_language(%{field: bad_value}) | |
| 50 | {:error, %Ecto.Changeset{}} | |
| 51 | ||
| 52 | """ | |
| 53 | def create_language(attrs \\ %{}) do | |
| 54 | %Language{} | |
| 55 | |> Language.changeset(attrs) | |
| 56 | 8 | |> Repo.insert() |
| 57 | end | |
| 58 | ||
| 59 | @doc """ | |
| 60 | Updates a language. | |
| 61 | ||
| 62 | ## Examples | |
| 63 | ||
| 64 | iex> update_language(language, %{field: new_value}) | |
| 65 | {:ok, %Language{}} | |
| 66 | ||
| 67 | iex> update_language(language, %{field: bad_value}) | |
| 68 | {:error, %Ecto.Changeset{}} | |
| 69 | ||
| 70 | """ | |
| 71 | def update_language(%Language{} = language, attrs) do | |
| 72 | language | |
| 73 | |> Language.changeset(attrs) | |
| 74 | 2 | |> Repo.update() |
| 75 | end | |
| 76 | ||
| 77 | @doc """ | |
| 78 | Deletes a language. | |
| 79 | ||
| 80 | ## Examples | |
| 81 | ||
| 82 | iex> delete_language(language) | |
| 83 | {:ok, %Language{}} | |
| 84 | ||
| 85 | iex> delete_language(language) | |
| 86 | {:error, %Ecto.Changeset{}} | |
| 87 | ||
| 88 | """ | |
| 89 | def delete_language(%Language{} = language) do | |
| 90 | 1 | Repo.delete(language) |
| 91 | end | |
| 92 | ||
| 93 | @doc """ | |
| 94 | Returns an `%Ecto.Changeset{}` for tracking language changes. | |
| 95 | ||
| 96 | ## Examples | |
| 97 | ||
| 98 | iex> change_language(language) | |
| 99 | %Ecto.Changeset{data: %Language{}} | |
| 100 | ||
| 101 | """ | |
| 102 | def change_language(%Language{} = language, attrs \\ %{}) do | |
| 103 | 1 | Language.changeset(language, attrs) |
| 104 | end | |
| 105 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Localisation.Language do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `Languages` entity, listing the languages of the world. | |
| 3 | ||
| 4 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 5 | from the [Geonames](https://geonames.org) project, specifically the | |
| 6 | `iso-languagecodes.txt` file, all languages' information, with the file | |
| 7 | annotation headers stripped off and column headers converted to lowercase, | |
| 8 | underscore-separated names. | |
| 9 | """ | |
| 10 | ||
| 11 | use Ecto.Schema | |
| 12 | import Ecto.Changeset | |
| 13 | ||
| 14 | @primary_key false | |
| 15 | @type t :: %__MODULE__{ | |
| 16 | "iso_639-1_language_code": String.t(), | |
| 17 | "iso_639-2_language_code": String.t(), | |
| 18 | "iso_639-3_language_code": String.t(), | |
| 19 | language_name: String.t() | |
| 20 | } | |
| 21 | 104 | schema "localisation_languages" do |
| 22 | field(:"iso_639-3_language_code", :string, primary_key: true) | |
| 23 | field(:"iso_639-2_language_code", :string) | |
| 24 | field(:"iso_639-1_language_code", :string) | |
| 25 | field(:language_name, :string) | |
| 26 | ||
| 27 | timestamps() | |
| 28 | end | |
| 29 | ||
| 30 | @doc false | |
| 31 | def changeset(language, attrs) do | |
| 32 | language | |
| 33 | |> cast(attrs, [ | |
| 34 | :"iso_639-3_language_code", | |
| 35 | :"iso_639-2_language_code", | |
| 36 | :"iso_639-1_language_code", | |
| 37 | :language_name | |
| 38 | ]) | |
| 39 | |> unique_constraint(:"iso_639-3") | |
| 40 | 11 | |> validate_required([:"iso_639-3_language_code", :language_name]) |
| 41 | end | |
| 42 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations do | |
| 1 | @moduledoc """ | |
| 2 | Location utilities for countries, subdivisions and cities. | |
| 3 | ||
| 4 | The module is largely a wrapper for Plausible Analytics' | |
| 5 | [`Location` package](https://github.com/plausible/location/tree/main). | |
| 6 | """ | |
| 7 | ||
| 8 | import Ecto.Query, warn: false | |
| 9 | alias Klepsidra.Repo | |
| 10 | ||
| 11 | alias Klepsidra.Locations.FeatureClass | |
| 12 | alias Klepsidra.Locations.FeatureCode | |
| 13 | alias Klepsidra.Locations.Continent | |
| 14 | alias Klepsidra.Locations.Country | |
| 15 | alias Klepsidra.Locations.AdministrativeDivisions1 | |
| 16 | alias Klepsidra.Locations.AdministrativeDivisions2 | |
| 17 | alias Klepsidra.Locations.City | |
| 18 | ||
| 19 | @doc """ | |
| 20 | Returns the list of locations_feature_classes. | |
| 21 | ||
| 22 | ## Examples | |
| 23 | ||
| 24 | iex> list_feature_classes() | |
| 25 | [%FeatureClass{}, ...] | |
| 26 | ||
| 27 | """ | |
| 28 | def list_feature_classes do | |
| 29 | 1 | Repo.all(FeatureClass) |
| 30 | end | |
| 31 | ||
| 32 | @doc """ | |
| 33 | Gets a single feature_class. | |
| 34 | ||
| 35 | Raises `Ecto.NoResultsError` if the Feature class does not exist. | |
| 36 | ||
| 37 | ## Examples | |
| 38 | ||
| 39 | iex> get_feature_class!(123) | |
| 40 | %FeatureClass{} | |
| 41 | ||
| 42 | iex> get_feature_class!(456) | |
| 43 | ** (Ecto.NoResultsError) | |
| 44 | ||
| 45 | """ | |
| 46 | def get_feature_class!(feature_class) do | |
| 47 | 3 | FeatureClass |> where([fc], fc.feature_class == ^feature_class) |> Repo.one() |
| 48 | end | |
| 49 | ||
| 50 | @doc """ | |
| 51 | Creates a feature_class. | |
| 52 | ||
| 53 | ## Examples | |
| 54 | ||
| 55 | iex> create_feature_class(%{field: value}) | |
| 56 | {:ok, %FeatureClass{}} | |
| 57 | ||
| 58 | iex> create_feature_class(%{field: bad_value}) | |
| 59 | {:error, %Ecto.Changeset{}} | |
| 60 | ||
| 61 | """ | |
| 62 | def create_feature_class(attrs \\ %{}) do | |
| 63 | %FeatureClass{} | |
| 64 | |> FeatureClass.changeset(attrs) | |
| 65 | 8 | |> Repo.insert() |
| 66 | end | |
| 67 | ||
| 68 | @doc """ | |
| 69 | Updates a feature_class. | |
| 70 | ||
| 71 | ## Examples | |
| 72 | ||
| 73 | iex> update_feature_class(feature_class, %{field: new_value}) | |
| 74 | {:ok, %FeatureClass{}} | |
| 75 | ||
| 76 | iex> update_feature_class(feature_class, %{field: bad_value}) | |
| 77 | {:error, %Ecto.Changeset{}} | |
| 78 | ||
| 79 | """ | |
| 80 | def update_feature_class(%FeatureClass{} = feature_class, attrs) do | |
| 81 | feature_class | |
| 82 | |> FeatureClass.changeset(attrs) | |
| 83 | 2 | |> Repo.update() |
| 84 | end | |
| 85 | ||
| 86 | @doc """ | |
| 87 | Deletes a feature_class. | |
| 88 | ||
| 89 | ## Examples | |
| 90 | ||
| 91 | iex> delete_feature_class(feature_class) | |
| 92 | {:ok, %FeatureClass{}} | |
| 93 | ||
| 94 | iex> delete_feature_class(feature_class) | |
| 95 | {:error, %Ecto.Changeset{}} | |
| 96 | ||
| 97 | """ | |
| 98 | def delete_feature_class(%FeatureClass{} = feature_class) do | |
| 99 | 1 | Repo.delete(feature_class) |
| 100 | end | |
| 101 | ||
| 102 | @doc """ | |
| 103 | Returns an `%Ecto.Changeset{}` for tracking feature_class changes. | |
| 104 | ||
| 105 | ## Examples | |
| 106 | ||
| 107 | iex> change_feature_class(feature_class) | |
| 108 | %Ecto.Changeset{data: %FeatureClass{}} | |
| 109 | ||
| 110 | """ | |
| 111 | def change_feature_class(%FeatureClass{} = feature_class, attrs \\ %{}) do | |
| 112 | 1 | FeatureClass.changeset(feature_class, attrs) |
| 113 | end | |
| 114 | ||
| 115 | @doc """ | |
| 116 | Returns the list of feature_codes. | |
| 117 | ||
| 118 | ## Examples | |
| 119 | ||
| 120 | iex> list_feature_codes() | |
| 121 | [%FeatureCode{}, ...] | |
| 122 | ||
| 123 | """ | |
| 124 | def list_feature_codes do | |
| 125 | 0 | Repo.all(FeatureCode) |
| 126 | end | |
| 127 | ||
| 128 | @doc """ | |
| 129 | Gets a single feature_code. | |
| 130 | ||
| 131 | Raises `Ecto.NoResultsError` if the Feature code does not exist. | |
| 132 | ||
| 133 | ## Examples | |
| 134 | ||
| 135 | iex> get_feature_code!("P", "PPL") | |
| 136 | %FeatureCode{} | |
| 137 | ||
| 138 | iex> get_feature_code!("X", "YYY") | |
| 139 | ** (Ecto.NoResultsError) | |
| 140 | ||
| 141 | """ | |
| 142 | def get_feature_code!(feature_code) do | |
| 143 | FeatureCode | |
| 144 | 0 | |> where([fc], fc.feature_code == ^feature_code) |
| 145 | 0 | |> Repo.one() |
| 146 | end | |
| 147 | ||
| 148 | @doc """ | |
| 149 | Creates a feature_code. | |
| 150 | ||
| 151 | ## Examples | |
| 152 | ||
| 153 | iex> create_feature_code(%{field: value}) | |
| 154 | {:ok, %FeatureCode{}} | |
| 155 | ||
| 156 | iex> create_feature_code(%{field: bad_value}) | |
| 157 | {:error, %Ecto.Changeset{}} | |
| 158 | ||
| 159 | """ | |
| 160 | def create_feature_code(attrs \\ %{}) do | |
| 161 | %FeatureCode{} | |
| 162 | |> FeatureCode.changeset(attrs) | |
| 163 | 0 | |> Repo.insert() |
| 164 | end | |
| 165 | ||
| 166 | @doc """ | |
| 167 | Updates a feature_code. | |
| 168 | ||
| 169 | ## Examples | |
| 170 | ||
| 171 | iex> update_feature_code(feature_code, %{field: new_value}) | |
| 172 | {:ok, %FeatureCode{}} | |
| 173 | ||
| 174 | iex> update_feature_code(feature_code, %{field: bad_value}) | |
| 175 | {:error, %Ecto.Changeset{}} | |
| 176 | ||
| 177 | """ | |
| 178 | def update_feature_code(%FeatureCode{} = feature_code, attrs) do | |
| 179 | feature_code | |
| 180 | |> FeatureCode.changeset(attrs) | |
| 181 | 0 | |> Repo.update() |
| 182 | end | |
| 183 | ||
| 184 | @doc """ | |
| 185 | Deletes a feature_code. | |
| 186 | ||
| 187 | ## Examples | |
| 188 | ||
| 189 | iex> delete_feature_code(feature_code) | |
| 190 | {:ok, %FeatureCode{}} | |
| 191 | ||
| 192 | iex> delete_feature_code(feature_code) | |
| 193 | {:error, %Ecto.Changeset{}} | |
| 194 | ||
| 195 | """ | |
| 196 | def delete_feature_code(%FeatureCode{} = feature_code) do | |
| 197 | 0 | Repo.delete(feature_code) |
| 198 | end | |
| 199 | ||
| 200 | @doc """ | |
| 201 | Returns an `%Ecto.Changeset{}` for tracking feature_code changes. | |
| 202 | ||
| 203 | ## Examples | |
| 204 | ||
| 205 | iex> change_feature_code(feature_code) | |
| 206 | %Ecto.Changeset{data: %FeatureCode{}} | |
| 207 | ||
| 208 | """ | |
| 209 | def change_feature_code(%FeatureCode{} = feature_code, attrs \\ %{}) do | |
| 210 | 0 | FeatureCode.changeset(feature_code, attrs) |
| 211 | end | |
| 212 | ||
| 213 | @doc """ | |
| 214 | Returns the list of locations_continents. | |
| 215 | ||
| 216 | ## Examples | |
| 217 | ||
| 218 | iex> list_continents() | |
| 219 | [%Continent{}, ...] | |
| 220 | ||
| 221 | """ | |
| 222 | def list_continents do | |
| 223 | 1 | Repo.all(Continent) |
| 224 | end | |
| 225 | ||
| 226 | @doc """ | |
| 227 | Gets a single continent. | |
| 228 | ||
| 229 | Raises `Ecto.NoResultsError` if the Continent does not exist. | |
| 230 | ||
| 231 | ## Examples | |
| 232 | ||
| 233 | iex> get_continent!(123) | |
| 234 | %Continent{} | |
| 235 | ||
| 236 | iex> get_continent!(456) | |
| 237 | ** (Ecto.NoResultsError) | |
| 238 | ||
| 239 | """ | |
| 240 | def get_continent!(continent_code) do | |
| 241 | 3 | Continent |> where([c], c.continent_code == ^continent_code) |> Repo.one() |
| 242 | end | |
| 243 | ||
| 244 | @doc """ | |
| 245 | Creates a continent. | |
| 246 | ||
| 247 | ## Examples | |
| 248 | ||
| 249 | iex> create_continent(%{field: value}) | |
| 250 | {:ok, %Continent{}} | |
| 251 | ||
| 252 | iex> create_continent(%{field: bad_value}) | |
| 253 | {:error, %Ecto.Changeset{}} | |
| 254 | ||
| 255 | """ | |
| 256 | def create_continent(attrs \\ %{}) do | |
| 257 | %Continent{} | |
| 258 | |> Continent.changeset(attrs) | |
| 259 | 8 | |> Repo.insert() |
| 260 | end | |
| 261 | ||
| 262 | @doc """ | |
| 263 | Updates a continent. | |
| 264 | ||
| 265 | ## Examples | |
| 266 | ||
| 267 | iex> update_continent(continent, %{field: new_value}) | |
| 268 | {:ok, %Continent{}} | |
| 269 | ||
| 270 | iex> update_continent(continent, %{field: bad_value}) | |
| 271 | {:error, %Ecto.Changeset{}} | |
| 272 | ||
| 273 | """ | |
| 274 | def update_continent(%Continent{} = continent, attrs) do | |
| 275 | continent | |
| 276 | |> Continent.changeset(attrs) | |
| 277 | 2 | |> Repo.update() |
| 278 | end | |
| 279 | ||
| 280 | @doc """ | |
| 281 | Deletes a continent. | |
| 282 | ||
| 283 | ## Examples | |
| 284 | ||
| 285 | iex> delete_continent(continent) | |
| 286 | {:ok, %Continent{}} | |
| 287 | ||
| 288 | iex> delete_continent(continent) | |
| 289 | {:error, %Ecto.Changeset{}} | |
| 290 | ||
| 291 | """ | |
| 292 | def delete_continent(%Continent{} = continent) do | |
| 293 | 1 | Repo.delete(continent) |
| 294 | end | |
| 295 | ||
| 296 | @doc """ | |
| 297 | Returns an `%Ecto.Changeset{}` for tracking continent changes. | |
| 298 | ||
| 299 | ## Examples | |
| 300 | ||
| 301 | iex> change_continent(continent) | |
| 302 | %Ecto.Changeset{data: %Continent{}} | |
| 303 | ||
| 304 | """ | |
| 305 | def change_continent(%Continent{} = continent, attrs \\ %{}) do | |
| 306 | 1 | Continent.changeset(continent, attrs) |
| 307 | end | |
| 308 | ||
| 309 | @doc """ | |
| 310 | Returns the list of countries. | |
| 311 | ||
| 312 | ## Examples | |
| 313 | ||
| 314 | iex> list_countries() | |
| 315 | [%Country{}, ...] | |
| 316 | ||
| 317 | """ | |
| 318 | def list_countries do | |
| 319 | 0 | Repo.all(Country) |
| 320 | end | |
| 321 | ||
| 322 | @doc """ | |
| 323 | Gets a single country. | |
| 324 | ||
| 325 | Raises `Ecto.NoResultsError` if the Country does not exist. | |
| 326 | ||
| 327 | ## Examples | |
| 328 | ||
| 329 | iex> get_country!(123) | |
| 330 | %Country{} | |
| 331 | ||
| 332 | iex> get_country!(456) | |
| 333 | ** (Ecto.NoResultsError) | |
| 334 | ||
| 335 | """ | |
| 336 | def get_country!(iso_country_code) do | |
| 337 | Country | |
| 338 | 0 | |> where([c], c.iso_country_code == ^iso_country_code) |
| 339 | 0 | |> Repo.one() |
| 340 | end | |
| 341 | ||
| 342 | @doc """ | |
| 343 | Creates a country. | |
| 344 | ||
| 345 | ## Examples | |
| 346 | ||
| 347 | iex> create_country(%{field: value}) | |
| 348 | {:ok, %Country{}} | |
| 349 | ||
| 350 | iex> create_country(%{field: bad_value}) | |
| 351 | {:error, %Ecto.Changeset{}} | |
| 352 | ||
| 353 | """ | |
| 354 | def create_country(attrs \\ %{}) do | |
| 355 | %Country{} | |
| 356 | |> Country.changeset(attrs) | |
| 357 | 0 | |> Repo.insert() |
| 358 | end | |
| 359 | ||
| 360 | @doc """ | |
| 361 | Updates a country. | |
| 362 | ||
| 363 | ## Examples | |
| 364 | ||
| 365 | iex> update_country(country, %{field: new_value}) | |
| 366 | {:ok, %Country{}} | |
| 367 | ||
| 368 | iex> update_country(country, %{field: bad_value}) | |
| 369 | {:error, %Ecto.Changeset{}} | |
| 370 | ||
| 371 | """ | |
| 372 | def update_country(%Country{} = country, attrs) do | |
| 373 | country | |
| 374 | |> Country.changeset(attrs) | |
| 375 | 0 | |> Repo.update() |
| 376 | end | |
| 377 | ||
| 378 | @doc """ | |
| 379 | Deletes a country. | |
| 380 | ||
| 381 | ## Examples | |
| 382 | ||
| 383 | iex> delete_country(country) | |
| 384 | {:ok, %Country{}} | |
| 385 | ||
| 386 | iex> delete_country(country) | |
| 387 | {:error, %Ecto.Changeset{}} | |
| 388 | ||
| 389 | """ | |
| 390 | def delete_country(%Country{} = country) do | |
| 391 | 0 | Repo.delete(country) |
| 392 | end | |
| 393 | ||
| 394 | @doc """ | |
| 395 | Returns an `%Ecto.Changeset{}` for tracking country changes. | |
| 396 | ||
| 397 | ## Examples | |
| 398 | ||
| 399 | iex> change_country(country) | |
| 400 | %Ecto.Changeset{data: %Country{}} | |
| 401 | ||
| 402 | """ | |
| 403 | def change_country(%Country{} = country, attrs \\ %{}) do | |
| 404 | 0 | Country.changeset(country, attrs) |
| 405 | end | |
| 406 | ||
| 407 | @doc """ | |
| 408 | Returns the list of level 1 administrative divisions. | |
| 409 | ||
| 410 | ## Examples | |
| 411 | ||
| 412 | iex> list_administrative_divisions_1() | |
| 413 | [%AdministrativeDivisions1{}, ...] | |
| 414 | ||
| 415 | """ | |
| 416 | def list_administrative_divisions_1 do | |
| 417 | 0 | Repo.all(AdministrativeDivisions1) |
| 418 | end | |
| 419 | ||
| 420 | @doc """ | |
| 421 | Gets a single level 1 administrative division. | |
| 422 | ||
| 423 | Raises `Ecto.NoResultsError` if the `administrative_division_1` does not exist. | |
| 424 | ||
| 425 | ## Examples | |
| 426 | ||
| 427 | iex> get_administrative_division_1!(123) | |
| 428 | %AdministrativeDivisions1{} | |
| 429 | ||
| 430 | iex> get_administrative_division_1!(456) | |
| 431 | ** (Ecto.NoResultsError) | |
| 432 | ||
| 433 | """ | |
| 434 | def get_administrative_division_1!(administrative_division_1_code) do | |
| 435 | AdministrativeDivisions1 | |
| 436 | 0 | |> where([ad1], ad1.administrative_division_1_code == ^administrative_division_1_code) |
| 437 | 0 | |> Repo.one() |
| 438 | end | |
| 439 | ||
| 440 | @doc """ | |
| 441 | Creates a new level 1 administrative division. | |
| 442 | ||
| 443 | ## Examples | |
| 444 | ||
| 445 | iex> create_administrative_division_1(%{field: value}) | |
| 446 | {:ok, %AdministrativeDivisions1{}} | |
| 447 | ||
| 448 | iex> create_administrative_division_1(%{field: bad_value}) | |
| 449 | {:error, %Ecto.Changeset{}} | |
| 450 | ||
| 451 | """ | |
| 452 | def create_administrative_division_1(attrs \\ %{}) do | |
| 453 | %AdministrativeDivisions1{} | |
| 454 | |> AdministrativeDivisions1.changeset(attrs) | |
| 455 | 0 | |> Repo.insert() |
| 456 | end | |
| 457 | ||
| 458 | @doc """ | |
| 459 | Updates a level 1 administrative division. | |
| 460 | ||
| 461 | ## Examples | |
| 462 | ||
| 463 | iex> update_administrative_division_1(administrative_division_1, %{field: new_value}) | |
| 464 | {:ok, %AdministrativeDivisions1{}} | |
| 465 | ||
| 466 | iex> update_administrative_division_1(administrative_division_1, %{field: bad_value}) | |
| 467 | {:error, %Ecto.Changeset{}} | |
| 468 | ||
| 469 | """ | |
| 470 | def update_administrative_division_1( | |
| 471 | %AdministrativeDivisions1{} = administrative_division_1, | |
| 472 | attrs | |
| 473 | ) do | |
| 474 | administrative_division_1 | |
| 475 | |> AdministrativeDivisions1.changeset(attrs) | |
| 476 | 0 | |> Repo.update() |
| 477 | end | |
| 478 | ||
| 479 | @doc """ | |
| 480 | Deletes a level 1 administrative division. | |
| 481 | ||
| 482 | ## Examples | |
| 483 | ||
| 484 | iex> delete_administrative_division_1(administrative_division_1) | |
| 485 | {:ok, %AdministrativeDivisions1{}} | |
| 486 | ||
| 487 | iex> delete_administrative_division_1(administrative_division_1) | |
| 488 | {:error, %Ecto.Changeset{}} | |
| 489 | ||
| 490 | """ | |
| 491 | def delete_administrative_division_1(%AdministrativeDivisions1{} = administrative_division_1) do | |
| 492 | 0 | Repo.delete(administrative_division_1) |
| 493 | end | |
| 494 | ||
| 495 | @doc """ | |
| 496 | Returns an `%Ecto.Changeset{}` for tracking `administrative_divisions_1` changes. | |
| 497 | ||
| 498 | ## Examples | |
| 499 | ||
| 500 | iex> change_administrative_division_1(administrative_division_1) | |
| 501 | %Ecto.Changeset{data: %AdministrativeDivisions1{}} | |
| 502 | ||
| 503 | """ | |
| 504 | def change_administrative_division_1( | |
| 505 | %AdministrativeDivisions1{} = administrative_division_1, | |
| 506 | 0 | attrs \\ %{} |
| 507 | ) do | |
| 508 | 0 | AdministrativeDivisions1.changeset(administrative_division_1, attrs) |
| 509 | end | |
| 510 | ||
| 511 | @doc """ | |
| 512 | Returns the list of level 2 administrative divisions. | |
| 513 | ||
| 514 | ## Examples | |
| 515 | ||
| 516 | iex> list_locations_administrative_divisions_2() | |
| 517 | [%AdministrativeDivisions2{}, ...] | |
| 518 | ||
| 519 | """ | |
| 520 | def list_locations_administrative_divisions_2 do | |
| 521 | 0 | Repo.all(AdministrativeDivisions2) |
| 522 | end | |
| 523 | ||
| 524 | @doc """ | |
| 525 | Gets a single level 2 administrative division. | |
| 526 | ||
| 527 | Raises `Ecto.NoResultsError` if the `administrative_division_2` does not exist. | |
| 528 | ||
| 529 | ## Examples | |
| 530 | ||
| 531 | iex> get_administrative_division_2!(123) | |
| 532 | %AdministrativeDivisions2{} | |
| 533 | ||
| 534 | iex> get_administrative_division_2!(456) | |
| 535 | ** (Ecto.NoResultsError) | |
| 536 | ||
| 537 | """ | |
| 538 | def get_administrative_division_2!(administrative_division_2_code) do | |
| 539 | AdministrativeDivisions2 | |
| 540 | 0 | |> where([ad2], ad2.administrative_division_2_code == ^administrative_division_2_code) |
| 541 | 0 | |> Repo.one() |
| 542 | end | |
| 543 | ||
| 544 | @doc """ | |
| 545 | Creates a level 2 administrative division. | |
| 546 | ||
| 547 | ## Examples | |
| 548 | ||
| 549 | iex> create_administrative_division_2(%{field: value}) | |
| 550 | {:ok, %AdministrativeDivisions2{}} | |
| 551 | ||
| 552 | iex> create_administrative_division_2(%{field: bad_value}) | |
| 553 | {:error, %Ecto.Changeset{}} | |
| 554 | ||
| 555 | """ | |
| 556 | def create_administrative_division_2(attrs \\ %{}) do | |
| 557 | %AdministrativeDivisions2{} | |
| 558 | |> AdministrativeDivisions2.changeset(attrs) | |
| 559 | 0 | |> Repo.insert() |
| 560 | end | |
| 561 | ||
| 562 | @doc """ | |
| 563 | Updates a level 2 administrative division. | |
| 564 | ||
| 565 | ## Examples | |
| 566 | ||
| 567 | iex> update_administrative_division_2(administrative_division_2, %{field: new_value}) | |
| 568 | {:ok, %AdministrativeDivisions2{}} | |
| 569 | ||
| 570 | iex> update_administrative_division_2(administrative_division_2, %{field: bad_value}) | |
| 571 | {:error, %Ecto.Changeset{}} | |
| 572 | ||
| 573 | """ | |
| 574 | def update_administrative_division_2( | |
| 575 | %AdministrativeDivisions2{} = administrative_division_2, | |
| 576 | attrs | |
| 577 | ) do | |
| 578 | administrative_division_2 | |
| 579 | |> AdministrativeDivisions2.changeset(attrs) | |
| 580 | 0 | |> Repo.update() |
| 581 | end | |
| 582 | ||
| 583 | @doc """ | |
| 584 | Deletes a level 2 administrative division. | |
| 585 | ||
| 586 | ## Examples | |
| 587 | ||
| 588 | iex> delete_administrative_division_2(administrative_division_2) | |
| 589 | {:ok, %AdministrativeDivisions2{}} | |
| 590 | ||
| 591 | iex> delete_administrative_division_2(administrative_division_2) | |
| 592 | {:error, %Ecto.Changeset{}} | |
| 593 | ||
| 594 | """ | |
| 595 | def delete_administrative_division_2(%AdministrativeDivisions2{} = administrative_division_2) do | |
| 596 | 0 | Repo.delete(administrative_division_2) |
| 597 | end | |
| 598 | ||
| 599 | @doc """ | |
| 600 | Returns an `%Ecto.Changeset{}` for tracking `administrative_division_2` changes. | |
| 601 | ||
| 602 | ## Examples | |
| 603 | ||
| 604 | iex> change_administrative_division_2(administrative_division_2) | |
| 605 | %Ecto.Changeset{data: %AdministrativeDivision2{}} | |
| 606 | ||
| 607 | """ | |
| 608 | def change_administrative_division_2( | |
| 609 | %AdministrativeDivisions2{} = administrative_division_2, | |
| 610 | 0 | attrs \\ %{} |
| 611 | ) do | |
| 612 | 0 | AdministrativeDivisions2.changeset(administrative_division_2, attrs) |
| 613 | end | |
| 614 | ||
| 615 | @doc """ | |
| 616 | Returns the list of cities. | |
| 617 | ||
| 618 | ## Examples | |
| 619 | ||
| 620 | iex> Locations.list_cities() | |
| 621 | [%Locations.City{}, ...] | |
| 622 | ||
| 623 | """ | |
| 624 | def list_cities do | |
| 625 | 0 | Repo.all(City) |
| 626 | end | |
| 627 | ||
| 628 | @doc """ | |
| 629 | Returns a list of cities containing the name fragment, sorted by | |
| 630 | descending population size, and by name in alphabetical order. | |
| 631 | ||
| 632 | ## Examples | |
| 633 | ||
| 634 | iex> Locations.list_cities_by_name("") | |
| 635 | [] | |
| 636 | ||
| 637 | iex> Locations.list_cities_by_name("london") | |
| 638 | [%{}, ...] | |
| 639 | """ | |
| 640 | @spec list_cities_by_name(name_filter :: String.t(), options :: keyword()) :: [map(), ...] | |
| 641 | 0 | def list_cities_by_name(name_filter, options \\ []) |
| 642 | ||
| 643 | 0 | def list_cities_by_name("", _), do: [] |
| 644 | ||
| 645 | def list_cities_by_name(name_filter, _options) when is_bitstring(name_filter) do | |
| 646 | # Keyword.get(options, :limit, 25) | |
| 647 | 0 | max_result_count = 25 |
| 648 | 0 | like_name = "%#{name_filter}%" |
| 649 | ||
| 650 | 0 | query = |
| 651 | 0 | from( |
| 652 | c in City, | |
| 653 | left_join: fc in FeatureCode, | |
| 654 | on: c.feature_code == fc.feature_code, | |
| 655 | left_join: co in Country, | |
| 656 | on: c.country_code == co.iso_country_code, | |
| 657 | where: like(c.ascii_name, ^like_name), | |
| 658 | left_join: ad in AdministrativeDivisions1, | |
| 659 | on: c.administrative_division_1_code == ad.administrative_division_1_code, | |
| 660 | order_by: [ | |
| 661 | asc: fc.order, | |
| 662 | desc: c.population, | |
| 663 | desc: co.population, | |
| 664 | desc: co.area, | |
| 665 | asc: c.name | |
| 666 | ], | |
| 667 | select: %{ | |
| 668 | id: c.id, | |
| 669 | name: c.name, | |
| 670 | level_1_division: ad.administrative_division_1_name |> coalesce(""), | |
| 671 | country_name: co.country_name, | |
| 672 | feature_description: fc.description | |
| 673 | }, | |
| 674 | limit: ^max_result_count | |
| 675 | ) | |
| 676 | ||
| 677 | 0 | Repo.all(query) |
| 678 | end | |
| 679 | ||
| 680 | 0 | def list_cities_by_name(_, _), do: [] |
| 681 | ||
| 682 | @doc """ | |
| 683 | Searches for cities, forming the result into an HTML usable | |
| 684 | %{value: id, label: city} map. | |
| 685 | """ | |
| 686 | @spec city_search(city_name :: String.t()) :: [map()] | |
| 687 | def city_search(city_name) when is_bitstring(city_name) do | |
| 688 | city_name | |
| 689 | |> list_cities_by_name(limit: 25) | |
| 690 | 0 | |> Enum.map(fn city -> |
| 691 | 0 | %{value: city.id, label: "#{city.name}, #{city.level_1_division} - #{city.country_name}"} |
| 692 | end) | |
| 693 | end | |
| 694 | ||
| 695 | 0 | def city_search(_name), do: [] |
| 696 | ||
| 697 | @doc """ | |
| 698 | Gets a single city. | |
| 699 | ||
| 700 | Raises `Ecto.NoResultsError` if the City does not exist. | |
| 701 | ||
| 702 | ## Examples | |
| 703 | ||
| 704 | iex> Locations.get_city!(123) | |
| 705 | %Locations.City{} | |
| 706 | ||
| 707 | iex> Locations.get_city!(456) | |
| 708 | ** (Ecto.NoResultsError) | |
| 709 | ||
| 710 | """ | |
| 711 | 0 | def get_city!(id), do: Repo.get!(City, id) |
| 712 | ||
| 713 | @doc """ | |
| 714 | Gets a single city, with level 1 administrative division or territory, | |
| 715 | and country name. | |
| 716 | ||
| 717 | Returns `nil` if the City does not exist. | |
| 718 | ||
| 719 | ## Examples | |
| 720 | ||
| 721 | iex> Locations.get_city_territory_and_country!(123) | |
| 722 | %Locations.City{} | |
| 723 | ||
| 724 | iex> Locations.get_city_territory_and_country!(456) | |
| 725 | nil | |
| 726 | ||
| 727 | """ | |
| 728 | @spec get_city_territory_and_country!(city_id :: Ecto.UUID.t()) :: City.t() | nil | |
| 729 | 0 | def get_city_territory_and_country!(""), do: nil |
| 730 | ||
| 731 | def get_city_territory_and_country!(city_id) when is_bitstring(city_id) do | |
| 732 | 0 | query = |
| 733 | 0 | from( |
| 734 | c in City, | |
| 735 | left_join: fc in FeatureCode, | |
| 736 | on: c.feature_code == fc.feature_code, | |
| 737 | left_join: co in Country, | |
| 738 | on: c.country_code == co.iso_country_code, | |
| 739 | left_join: ad in AdministrativeDivisions1, | |
| 740 | on: c.administrative_division_1_code == ad.administrative_division_1_code, | |
| 741 | where: c.id == ^city_id, | |
| 742 | select: %{ | |
| 743 | id: c.id, | |
| 744 | name: c.name, | |
| 745 | level_1_division: ad.administrative_division_1_name |> coalesce(""), | |
| 746 | country_name: co.country_name, | |
| 747 | feature_description: fc.description | |
| 748 | } | |
| 749 | ) | |
| 750 | ||
| 751 | 0 | Repo.one(query) |
| 752 | end | |
| 753 | ||
| 754 | 0 | def get_city_territory_and_country!(_), do: nil |
| 755 | ||
| 756 | @doc """ | |
| 757 | Creates a city. | |
| 758 | ||
| 759 | ## Examples | |
| 760 | ||
| 761 | iex> Locations.create_city(%{name: "Londinium"}) | |
| 762 | {:ok, %Locations.City{}} | |
| 763 | ||
| 764 | iex> Locations.create_city(%{name: 123_456}) | |
| 765 | {:error, %Ecto.Changeset{}} | |
| 766 | ||
| 767 | """ | |
| 768 | def create_city(attrs \\ %{}) do | |
| 769 | %City{} | |
| 770 | |> City.changeset(attrs) | |
| 771 | 0 | |> Repo.insert() |
| 772 | end | |
| 773 | ||
| 774 | @doc """ | |
| 775 | Updates a city. | |
| 776 | ||
| 777 | ## Examples | |
| 778 | ||
| 779 | iex> Locations.update_city(city, %{name: "Troy"}) | |
| 780 | {:ok, %Locations.City{}} | |
| 781 | ||
| 782 | iex> Locations.update_city(city, %{field: 300}) | |
| 783 | {:error, %Ecto.Changeset{}} | |
| 784 | ||
| 785 | """ | |
| 786 | def update_city(%City{} = city, attrs) do | |
| 787 | city | |
| 788 | |> City.changeset(attrs) | |
| 789 | 0 | |> Repo.update() |
| 790 | end | |
| 791 | ||
| 792 | @doc """ | |
| 793 | Deletes a city. | |
| 794 | ||
| 795 | ## Examples | |
| 796 | ||
| 797 | iex> Locations.delete_city(city) | |
| 798 | {:ok, %Locations.City{}} | |
| 799 | ||
| 800 | iex> Locations.delete_city(city) | |
| 801 | {:error, %Ecto.Changeset{}} | |
| 802 | ||
| 803 | """ | |
| 804 | def delete_city(%City{} = city) do | |
| 805 | 0 | Repo.delete(city) |
| 806 | end | |
| 807 | ||
| 808 | @doc """ | |
| 809 | Returns an `%Ecto.Changeset{}` for tracking city changes. | |
| 810 | ||
| 811 | ## Examples | |
| 812 | ||
| 813 | iex> Locations.change_city(city) | |
| 814 | %Ecto.Changeset{data: %Locations.City{}} | |
| 815 | ||
| 816 | """ | |
| 817 | def change_city(%City{} = city, attrs \\ %{}) do | |
| 818 | 0 | City.changeset(city, attrs) |
| 819 | end | |
| 820 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.AdministrativeDivisions1 do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `AdministrativeDivision1` entity, listing GeoNames' | |
| 3 | country code and administrative division 1 codes, data that is used in | |
| 4 | their cities database. | |
| 5 | ||
| 6 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 7 | from the [Geonames](https://geonames.org) project, specifically the | |
| 8 | `admin1CodesASCII.txt` file, with column headers inserted. | |
| 9 | """ | |
| 10 | ||
| 11 | use Ecto.Schema | |
| 12 | import Ecto.Changeset | |
| 13 | ||
| 14 | @primary_key false | |
| 15 | @type t :: %__MODULE__{ | |
| 16 | administrative_division_1_code: String.t(), | |
| 17 | country_code: String.t(), | |
| 18 | administrative_division_1_name: String.t(), | |
| 19 | administrative_division_1_ascii_name: String.t(), | |
| 20 | geoname_id: integer() | |
| 21 | } | |
| 22 | 0 | schema "locations_administrative_divisions_1" do |
| 23 | field(:administrative_division_1_code, :string, primary_key: true) | |
| 24 | field(:country_code, :string) | |
| 25 | field(:administrative_division_1_name, :string) | |
| 26 | field(:administrative_division_1_ascii_name, :string) | |
| 27 | field(:geoname_id, :integer) | |
| 28 | ||
| 29 | timestamps() | |
| 30 | end | |
| 31 | ||
| 32 | @doc false | |
| 33 | def changeset(administrative_divisions_1, attrs) do | |
| 34 | administrative_divisions_1 | |
| 35 | |> cast(attrs, [ | |
| 36 | :administrative_division_1_code, | |
| 37 | :country_code, | |
| 38 | :administrative_division_1_name, | |
| 39 | :administrative_division_1_ascii_name, | |
| 40 | :geoname_id | |
| 41 | ]) | |
| 42 | |> unique_constraint(:administrative_division_1_code) | |
| 43 | |> foreign_key_constraint(:country_code) | |
| 44 | 0 | |> validate_required([ |
| 45 | :administrative_division_1_code, | |
| 46 | :country_code, | |
| 47 | :administrative_division_1_name, | |
| 48 | :administrative_division_1_ascii_name, | |
| 49 | :geoname_id | |
| 50 | ]) | |
| 51 | end | |
| 52 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.AdministrativeDivisions2 do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `AdministrativeDivision2` entity, listing GeoNames' | |
| 3 | country code, administrative division 1, and administrative division 2 codes, | |
| 4 | data that is used in their cities database. | |
| 5 | ||
| 6 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 7 | from the [Geonames](https://geonames.org) project, specifically the | |
| 8 | `admin2Codes.txt` file, with column headers inserted. | |
| 9 | """ | |
| 10 | ||
| 11 | use Ecto.Schema | |
| 12 | import Ecto.Changeset | |
| 13 | ||
| 14 | @primary_key false | |
| 15 | @type t :: %__MODULE__{ | |
| 16 | administrative_division_2_code: String.t(), | |
| 17 | administrative_division_1_code: String.t(), | |
| 18 | country_code: String.t(), | |
| 19 | administrative_division_2_name: String.t(), | |
| 20 | administrative_division_2_ascii_name: String.t(), | |
| 21 | geoname_id: integer() | |
| 22 | } | |
| 23 | 0 | schema "locations_administrative_divisions_2" do |
| 24 | field(:administrative_division_2_code, :string, primary_key: true) | |
| 25 | field(:administrative_division_1_code, :string) | |
| 26 | field(:country_code, :string) | |
| 27 | field(:administrative_division_2_name, :string) | |
| 28 | field(:administrative_division_2_ascii_name, :string) | |
| 29 | field(:geoname_id, :integer) | |
| 30 | ||
| 31 | timestamps() | |
| 32 | end | |
| 33 | ||
| 34 | @doc false | |
| 35 | def changeset(administrative_division2, attrs) do | |
| 36 | administrative_division2 | |
| 37 | |> cast(attrs, [ | |
| 38 | :administrative_division_2_code, | |
| 39 | :administrative_division_1_code, | |
| 40 | :country_code, | |
| 41 | :administrative_division_2_name, | |
| 42 | :administrative_division_2_ascii_name, | |
| 43 | :geoname_id | |
| 44 | ]) | |
| 45 | |> unique_constraint(:administrative_division_2_code) | |
| 46 | |> foreign_key_constraint(:administrative_division_1_code) | |
| 47 | |> foreign_key_constraint(:country_code) | |
| 48 | 0 | |> validate_required([ |
| 49 | :administrative_division_2_code, | |
| 50 | :administrative_division_1_code, | |
| 51 | :country_code, | |
| 52 | :administrative_division_2_name, | |
| 53 | :administrative_division_2_ascii_name, | |
| 54 | :geoname_id | |
| 55 | ]) | |
| 56 | end | |
| 57 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.City do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `City` entity, used to select cities of the world. | |
| 3 | ||
| 4 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 5 | from the [Geonames](https://geonames.org) project, specifically the `cities500.zip` | |
| 6 | file, all cities with a population greater than 500. | |
| 7 | """ | |
| 8 | ||
| 9 | use Ecto.Schema | |
| 10 | import Ecto.Changeset | |
| 11 | alias Klepsidra.Locations | |
| 12 | ||
| 13 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 14 | @foreign_key_type Ecto.UUID | |
| 15 | ||
| 16 | @type t :: %__MODULE__{ | |
| 17 | id: Ecto.UUID.t(), | |
| 18 | geoname_id: integer(), | |
| 19 | name: String.t(), | |
| 20 | ascii_name: String.t(), | |
| 21 | alternate_names: String.t(), | |
| 22 | latitude: float(), | |
| 23 | longitude: float(), | |
| 24 | feature_class: String.t(), | |
| 25 | feature_code: String.t(), | |
| 26 | country_code: String.t(), | |
| 27 | cc2: String.t(), | |
| 28 | administrative_division_1_code: String.t(), | |
| 29 | administrative_division_2_code: String.t(), | |
| 30 | administrative_division_3_code: String.t(), | |
| 31 | administrative_division_4_code: String.t(), | |
| 32 | population: integer(), | |
| 33 | elevation: integer(), | |
| 34 | dem: integer(), | |
| 35 | timezone: String.t(), | |
| 36 | modification_date: Date.t() | |
| 37 | } | |
| 38 | 0 | schema "locations_cities" do |
| 39 | field(:geoname_id, :integer) | |
| 40 | field(:name, :string) | |
| 41 | field(:ascii_name, :string) | |
| 42 | field(:alternate_names, :string) | |
| 43 | field(:latitude, :float) | |
| 44 | field(:longitude, :float) | |
| 45 | ||
| 46 | field(:feature_class, :binary_id) | |
| 47 | field(:feature_code, :binary_id) | |
| 48 | ||
| 49 | field(:country_code, :string) | |
| 50 | field(:cc2, :string) | |
| 51 | field(:administrative_division_1_code, :string) | |
| 52 | field(:administrative_division_2_code, :string) | |
| 53 | field(:administrative_division_3_code, :string) | |
| 54 | field(:administrative_division_4_code, :string) | |
| 55 | field(:population, :integer) | |
| 56 | field(:elevation, :integer) | |
| 57 | field(:dem, :integer) | |
| 58 | field(:timezone, :string) | |
| 59 | field(:modification_date, :date) | |
| 60 | ||
| 61 | timestamps() | |
| 62 | end | |
| 63 | ||
| 64 | @doc false | |
| 65 | def changeset(city, attrs) do | |
| 66 | city | |
| 67 | |> cast(attrs, [ | |
| 68 | :geoname_id, | |
| 69 | :name, | |
| 70 | :ascii_name, | |
| 71 | :alternate_names, | |
| 72 | :latitude, | |
| 73 | :longitude, | |
| 74 | :feature_class, | |
| 75 | :feature_code, | |
| 76 | :country_code, | |
| 77 | :cc2, | |
| 78 | :administrative_division_1_code, | |
| 79 | :administrative_division_2_code, | |
| 80 | :administrative_division_3_code, | |
| 81 | :administrative_division_4_code, | |
| 82 | :population, | |
| 83 | :elevation, | |
| 84 | :dem, | |
| 85 | :timezone, | |
| 86 | :modification_date | |
| 87 | ]) | |
| 88 | |> unique_constraint(:geoname_id) | |
| 89 | |> foreign_key_constraint(:feature_class, | |
| 90 | name: :FK_locations_cities_locations_feature_classes_4 | |
| 91 | ) | |
| 92 | |> foreign_key_constraint(:feature_code, name: :FK_locations_cities_locations_feature_codes_5) | |
| 93 | |> foreign_key_constraint(:country_code, name: :FK_locations_cities_locations_countries_3) | |
| 94 | |> foreign_key_constraint(:administrative_divisions_1_code, | |
| 95 | name: :FK_locations_cities_locations_administrative_divisions_1 | |
| 96 | ) | |
| 97 | 0 | |> validate_required([ |
| 98 | :geoname_id, | |
| 99 | :name, | |
| 100 | :latitude, | |
| 101 | :longitude, | |
| 102 | :feature_class, | |
| 103 | :feature_code, | |
| 104 | :country_code, | |
| 105 | :population, | |
| 106 | :timezone, | |
| 107 | :modification_date | |
| 108 | ]) | |
| 109 | end | |
| 110 | ||
| 111 | @doc """ | |
| 112 | Constructs an HTML `select` option for a single city entity, for use by | |
| 113 | the `live_select` live component. | |
| 114 | ||
| 115 | Given a current `location_id`, a foreign key reference to a city in the | |
| 116 | `locations_cities` table, calls the `get_city_territory_and_country/1` | |
| 117 | query, obtaining necessary fields to construct a full, unambiguous, | |
| 118 | city name. | |
| 119 | ||
| 120 | ## Returns | |
| 121 | ||
| 122 | Returns a single map: | |
| 123 | ``` | |
| 124 | %{ | |
| 125 | label: << city_name, territory - country name >>, | |
| 126 | value: << city_id (UUID) >> | |
| 127 | ``` | |
| 128 | ||
| 129 | ## Examples | |
| 130 | ||
| 131 | iex> city_option_as_html_select(UUID) | |
| 132 | %{label: "...", value: "UUID"} | |
| 133 | ||
| 134 | iex> city_option_as_html_select(123) | |
| 135 | %{label: "", value: ""} | |
| 136 | """ | |
| 137 | @spec city_option_for_select(city_id :: Ecto.UUID.t()) :: %{ | |
| 138 | label: String.t(), | |
| 139 | value: Ecto.UUID.t() | String.t() | |
| 140 | } | |
| 141 | def city_option_for_select(city_id) when is_bitstring(city_id) do | |
| 142 | 0 | case Locations.get_city_territory_and_country!(city_id) do |
| 143 | nil -> | |
| 144 | 0 | %{label: "", value: ""} |
| 145 | ||
| 146 | city -> | |
| 147 | 0 | %{ |
| 148 | 0 | label: "#{city.name}, #{city.level_1_division} - #{city.country_name}", |
| 149 | 0 | value: city.id |
| 150 | } | |
| 151 | end | |
| 152 | end | |
| 153 | ||
| 154 | 0 | def city_option_as_html_select(_), do: %{label: "", value: ""} |
| 155 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.Continent do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `Continents` entity, listing continents of the world. | |
| 3 | ||
| 4 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 5 | from the [Geonames](https://geonames.org) project, specifically the | |
| 6 | `continent_codes.csv` file (itself generated from the description on | |
| 7 | https://download.geonames.org/export/dump/), with column headers added. | |
| 8 | """ | |
| 9 | ||
| 10 | use Ecto.Schema | |
| 11 | import Ecto.Changeset | |
| 12 | ||
| 13 | @primary_key false | |
| 14 | @type t :: %__MODULE__{ | |
| 15 | continent_code: String.t(), | |
| 16 | continent_name: String.t(), | |
| 17 | geoname_id: integer() | |
| 18 | } | |
| 19 | 104 | schema "locations_continents" do |
| 20 | field(:continent_code, :string, primary_key: true) | |
| 21 | field(:continent_name, :string) | |
| 22 | field(:geoname_id, :integer) | |
| 23 | ||
| 24 | timestamps() | |
| 25 | end | |
| 26 | ||
| 27 | @doc false | |
| 28 | def changeset(continent, attrs) do | |
| 29 | continent | |
| 30 | |> cast(attrs, [:continent_code, :continent_name, :geoname_id]) | |
| 31 | |> unique_constraint(:continent_code) | |
| 32 | 11 | |> validate_required([:continent_code, :continent_name, :geoname_id]) |
| 33 | end | |
| 34 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.Country do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `Country` entity, listing the countries of the world. | |
| 3 | ||
| 4 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 5 | from the [Geonames](https://geonames.org) project, specifically the `countryInfo.txt` | |
| 6 | file, all countries' information, with the file annotation headers stripped off | |
| 7 | and column headers converted to lowercase, underscore-separated names. | |
| 8 | """ | |
| 9 | ||
| 10 | use Ecto.Schema | |
| 11 | import Ecto.Changeset | |
| 12 | ||
| 13 | @primary_key false | |
| 14 | @type t :: %__MODULE__{ | |
| 15 | iso_country_code: String.t(), | |
| 16 | iso_3_country_code: String.t(), | |
| 17 | iso_numeric_country_code: integer(), | |
| 18 | fips_country_code: String.t(), | |
| 19 | country_name: String.t(), | |
| 20 | capital: String.t(), | |
| 21 | area: float(), | |
| 22 | population: integer(), | |
| 23 | continent_code: String.t(), | |
| 24 | tld: String.t(), | |
| 25 | currency_code: String.t(), | |
| 26 | currency_name: String.t(), | |
| 27 | phone: String.t(), | |
| 28 | postal_code_format: String.t(), | |
| 29 | postal_code_regex: String.t(), | |
| 30 | languages: String.t(), | |
| 31 | geoname_id: integer(), | |
| 32 | neighbours: String.t(), | |
| 33 | equivalent_fips_code: String.t() | |
| 34 | } | |
| 35 | 0 | schema "locations_countries" do |
| 36 | field(:iso_country_code, :string, primary_key: true) | |
| 37 | field(:iso_3_country_code, :string) | |
| 38 | field(:iso_numeric_country_code, :integer) | |
| 39 | field(:fips_country_code, :string) | |
| 40 | field(:country_name, :string) | |
| 41 | field(:capital, :string) | |
| 42 | field(:area, :float) | |
| 43 | field(:population, :integer) | |
| 44 | field(:continent_code, :string) | |
| 45 | field(:tld, :string) | |
| 46 | field(:currency_code, :string) | |
| 47 | field(:currency_name, :string) | |
| 48 | field(:phone, :string) | |
| 49 | field(:postal_code_format, :string) | |
| 50 | field(:postal_code_regex, :string) | |
| 51 | field(:languages, :string) | |
| 52 | field(:geoname_id, :integer) | |
| 53 | field(:neighbours, :string) | |
| 54 | field(:equivalent_fips_code, :string) | |
| 55 | ||
| 56 | timestamps() | |
| 57 | end | |
| 58 | ||
| 59 | @doc false | |
| 60 | def changeset(country, attrs) do | |
| 61 | country | |
| 62 | |> cast(attrs, [ | |
| 63 | :iso_country_code, | |
| 64 | :iso_3_country_code, | |
| 65 | :iso_numeric_country_code, | |
| 66 | :fips_country_code, | |
| 67 | :country_name, | |
| 68 | :capital, | |
| 69 | :area, | |
| 70 | :population, | |
| 71 | :continent_code, | |
| 72 | :tld, | |
| 73 | :currency_code, | |
| 74 | :currency_name, | |
| 75 | :phone, | |
| 76 | :postal_code_format, | |
| 77 | :postal_code_regex, | |
| 78 | :languages, | |
| 79 | :geoname_id, | |
| 80 | :neighbours, | |
| 81 | :equivalent_fips_code | |
| 82 | ]) | |
| 83 | |> unique_constraint([ | |
| 84 | :iso_country_code, | |
| 85 | :iso_3_country_code, | |
| 86 | :iso_numeric_country_code, | |
| 87 | :geoname_id | |
| 88 | ]) | |
| 89 | |> foreign_key_constraint(:continent_code) | |
| 90 | 0 | |> validate_required([ |
| 91 | :iso_country_code, | |
| 92 | :iso_3_country_code, | |
| 93 | :iso_numeric_country_code, | |
| 94 | :country_name, | |
| 95 | :area, | |
| 96 | :population, | |
| 97 | :continent_code, | |
| 98 | :geoname_id | |
| 99 | ]) | |
| 100 | end | |
| 101 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.FeatureClass do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `FeatureClass` entity, listing GeoNames' | |
| 3 | feature classes, used for categorising locations around the world. | |
| 4 | This data is used in their cities database. | |
| 5 | ||
| 6 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 7 | from the [Geonames](https://geonames.org) project, specifically the | |
| 8 | `feature_classes.csv` file (itself generated from the description on | |
| 9 | https://download.geonames.org/export/dump/), with column headers added. | |
| 10 | """ | |
| 11 | ||
| 12 | use Ecto.Schema | |
| 13 | import Ecto.Changeset | |
| 14 | ||
| 15 | @primary_key false | |
| 16 | @type t :: %__MODULE__{ | |
| 17 | feature_class: String.t(), | |
| 18 | description: String.t() | |
| 19 | } | |
| 20 | 104 | schema "locations_feature_classes" do |
| 21 | field(:feature_class, :string, primary_key: true) | |
| 22 | field(:description, :string) | |
| 23 | ||
| 24 | timestamps() | |
| 25 | end | |
| 26 | ||
| 27 | @doc false | |
| 28 | def changeset(feature_class, attrs) do | |
| 29 | feature_class | |
| 30 | |> cast(attrs, [:feature_class, :description]) | |
| 31 | |> unique_constraint(:feature_class) | |
| 32 | 11 | |> validate_required([:feature_class]) |
| 33 | end | |
| 34 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Locations.FeatureCode do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `FeatureCode` entity, listing GeoNames' | |
| 3 | feature classes and codes, categorising locations around the world. This | |
| 4 | data is used in their cities database. | |
| 5 | ||
| 6 | This is not meant to be a user-editable entity, imported on a periodic basis | |
| 7 | from the [Geonames](https://geonames.org) project, specifically the `featureCodes.txt` | |
| 8 | file, with column headers converted to lowercase, underscore-separated names. | |
| 9 | """ | |
| 10 | ||
| 11 | use Ecto.Schema | |
| 12 | import Ecto.Changeset | |
| 13 | ||
| 14 | @primary_key false | |
| 15 | @type t :: %__MODULE__{ | |
| 16 | feature_code: String.t(), | |
| 17 | feature_class: String.t(), | |
| 18 | order: integer(), | |
| 19 | description: String.t(), | |
| 20 | note: String.t() | |
| 21 | } | |
| 22 | 0 | schema "locations_feature_codes" do |
| 23 | field(:feature_code, :string, primary_key: true) | |
| 24 | field(:feature_class, :string) | |
| 25 | field(:order, :integer) | |
| 26 | field(:description, :string) | |
| 27 | field(:note, :string) | |
| 28 | ||
| 29 | timestamps() | |
| 30 | end | |
| 31 | ||
| 32 | @doc false | |
| 33 | def changeset(feature_code, attrs) do | |
| 34 | feature_code | |
| 35 | |> cast(attrs, [:feature_code, :feature_class, :description, :note, :order]) | |
| 36 | |> unique_constraint(:feature_code) | |
| 37 | |> foreign_key_constraint(:feature_class) | |
| 38 | 0 | |> validate_required([:feature_code, :feature_class, :order, :description]) |
| 39 | end | |
| 40 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Mailer do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use Swoosh.Mailer, otp_app: :klepsidra | |
| 4 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Projects do | |
| 1 | @moduledoc """ | |
| 2 | The Projects context. | |
| 3 | """ | |
| 4 | ||
| 5 | import Ecto.Query, warn: false | |
| 6 | alias Klepsidra.Repo | |
| 7 | ||
| 8 | alias Klepsidra.Projects.Project | |
| 9 | ||
| 10 | @doc """ | |
| 11 | Returns the list of projects. | |
| 12 | ||
| 13 | ## Examples | |
| 14 | ||
| 15 | iex> list_projects() | |
| 16 | [%Project{}, ...] | |
| 17 | ||
| 18 | """ | |
| 19 | def list_projects do | |
| 20 | 9 | Project |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all() |
| 21 | end | |
| 22 | ||
| 23 | @doc """ | |
| 24 | Returns the list of active projects. | |
| 25 | ||
| 26 | ## Examples | |
| 27 | ||
| 28 | iex> list_active_projects() | |
| 29 | [%Project{}, ...] | |
| 30 | ||
| 31 | """ | |
| 32 | def list_active_projects do | |
| 33 | Project | |
| 34 | |> where(active: true) | |
| 35 | 2 | |> order_by(asc: fragment("name COLLATE NOCASE")) |
| 36 | 2 | |> Repo.all() |
| 37 | end | |
| 38 | ||
| 39 | @doc """ | |
| 40 | Gets a single project. | |
| 41 | ||
| 42 | Raises `Ecto.NoResultsError` if the Project does not exist. | |
| 43 | ||
| 44 | ## Examples | |
| 45 | ||
| 46 | iex> get_project!(123) | |
| 47 | %Project{} | |
| 48 | ||
| 49 | iex> get_project!(456) | |
| 50 | ** (Ecto.NoResultsError) | |
| 51 | ||
| 52 | """ | |
| 53 | 15 | def get_project!(id), do: Repo.get!(Project, id) |
| 54 | ||
| 55 | @doc """ | |
| 56 | Creates a project. | |
| 57 | ||
| 58 | ## Examples | |
| 59 | ||
| 60 | iex> create_project(%{field: value}) | |
| 61 | {:ok, %Project{}} | |
| 62 | ||
| 63 | iex> create_project(%{field: bad_value}) | |
| 64 | {:error, %Ecto.Changeset{}} | |
| 65 | ||
| 66 | """ | |
| 67 | def create_project(attrs \\ %{}) do | |
| 68 | %Project{} | |
| 69 | |> Project.changeset(attrs) | |
| 70 | 15 | |> Repo.insert() |
| 71 | end | |
| 72 | ||
| 73 | @doc """ | |
| 74 | Updates a project. | |
| 75 | ||
| 76 | ## Examples | |
| 77 | ||
| 78 | iex> update_project(project, %{field: new_value}) | |
| 79 | {:ok, %Project{}} | |
| 80 | ||
| 81 | iex> update_project(project, %{field: bad_value}) | |
| 82 | {:error, %Ecto.Changeset{}} | |
| 83 | ||
| 84 | """ | |
| 85 | def update_project(%Project{} = project, attrs) do | |
| 86 | project | |
| 87 | |> Project.changeset(attrs) | |
| 88 | 4 | |> Repo.update() |
| 89 | end | |
| 90 | ||
| 91 | @doc """ | |
| 92 | Deletes a project. | |
| 93 | ||
| 94 | ## Examples | |
| 95 | ||
| 96 | iex> delete_project(project) | |
| 97 | {:ok, %Project{}} | |
| 98 | ||
| 99 | iex> delete_project(project) | |
| 100 | {:error, %Ecto.Changeset{}} | |
| 101 | ||
| 102 | """ | |
| 103 | def delete_project(%Project{} = project) do | |
| 104 | 2 | Repo.delete(project) |
| 105 | end | |
| 106 | ||
| 107 | @doc """ | |
| 108 | Returns an `%Ecto.Changeset{}` for tracking project changes. | |
| 109 | ||
| 110 | ## Examples | |
| 111 | ||
| 112 | iex> change_project(project) | |
| 113 | %Ecto.Changeset{data: %Project{}} | |
| 114 | ||
| 115 | """ | |
| 116 | def change_project(%Project{} = project, attrs \\ %{}) do | |
| 117 | 7 | Project.changeset(project, attrs) |
| 118 | end | |
| 119 | ||
| 120 | alias Klepsidra.Projects.Note | |
| 121 | ||
| 122 | @doc """ | |
| 123 | Returns the list of project_notes. | |
| 124 | ||
| 125 | ## Examples | |
| 126 | ||
| 127 | iex> list_project_notes() | |
| 128 | [%Note{}, ...] | |
| 129 | ||
| 130 | """ | |
| 131 | def list_project_notes do | |
| 132 | 0 | Repo.all(Note) |
| 133 | end | |
| 134 | ||
| 135 | @doc """ | |
| 136 | Gets a single note. | |
| 137 | ||
| 138 | Raises `Ecto.NoResultsError` if the Note does not exist. | |
| 139 | ||
| 140 | ## Examples | |
| 141 | ||
| 142 | iex> get_note!(123) | |
| 143 | %Note{} | |
| 144 | ||
| 145 | iex> get_note!(456) | |
| 146 | ** (Ecto.NoResultsError) | |
| 147 | ||
| 148 | """ | |
| 149 | 0 | def get_note!(id), do: Repo.get!(Note, id) |
| 150 | ||
| 151 | @doc """ | |
| 152 | Creates a note. | |
| 153 | ||
| 154 | ## Examples | |
| 155 | ||
| 156 | iex> create_note(%{field: value}) | |
| 157 | {:ok, %Note{}} | |
| 158 | ||
| 159 | iex> create_note(%{field: bad_value}) | |
| 160 | {:error, %Ecto.Changeset{}} | |
| 161 | ||
| 162 | """ | |
| 163 | def create_note(attrs \\ %{}) do | |
| 164 | %Note{} | |
| 165 | |> Note.changeset(attrs) | |
| 166 | 0 | |> Repo.insert() |
| 167 | end | |
| 168 | ||
| 169 | @doc """ | |
| 170 | Updates a note. | |
| 171 | ||
| 172 | ## Examples | |
| 173 | ||
| 174 | iex> update_note(note, %{field: new_value}) | |
| 175 | {:ok, %Note{}} | |
| 176 | ||
| 177 | iex> update_note(note, %{field: bad_value}) | |
| 178 | {:error, %Ecto.Changeset{}} | |
| 179 | ||
| 180 | """ | |
| 181 | def update_note(%Note{} = note, attrs) do | |
| 182 | note | |
| 183 | |> Note.changeset(attrs) | |
| 184 | 0 | |> Repo.update() |
| 185 | end | |
| 186 | ||
| 187 | @doc """ | |
| 188 | Deletes a note. | |
| 189 | ||
| 190 | ## Examples | |
| 191 | ||
| 192 | iex> delete_note(note) | |
| 193 | {:ok, %Note{}} | |
| 194 | ||
| 195 | iex> delete_note(note) | |
| 196 | {:error, %Ecto.Changeset{}} | |
| 197 | ||
| 198 | """ | |
| 199 | def delete_note(%Note{} = note) do | |
| 200 | 0 | Repo.delete(note) |
| 201 | end | |
| 202 | ||
| 203 | @doc """ | |
| 204 | Returns an `%Ecto.Changeset{}` for tracking note changes. | |
| 205 | ||
| 206 | ## Examples | |
| 207 | ||
| 208 | iex> change_note(note) | |
| 209 | %Ecto.Changeset{data: %Note{}} | |
| 210 | ||
| 211 | """ | |
| 212 | def change_note(%Note{} = note, attrs \\ %{}) do | |
| 213 | 0 | Note.changeset(note, attrs) |
| 214 | end | |
| 215 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Projects.Note do | |
| 1 | @moduledoc """ | |
| 2 | Defines the schema for the project `notes` entity, annotations | |
| 3 | of ongoing management of projects. | |
| 4 | """ | |
| 5 | ||
| 6 | use Ecto.Schema | |
| 7 | import Ecto.Changeset | |
| 8 | ||
| 9 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 10 | @foreign_key_type Ecto.UUID | |
| 11 | ||
| 12 | @type t :: %__MODULE__{ | |
| 13 | note: String.t(), | |
| 14 | project_id: binary() | |
| 15 | } | |
| 16 | 0 | schema "project_notes" do |
| 17 | field :note, :string | |
| 18 | belongs_to :project, Project, type: Ecto.UUID | |
| 19 | ||
| 20 | timestamps() | |
| 21 | end | |
| 22 | ||
| 23 | @doc false | |
| 24 | def changeset(note, attrs) do | |
| 25 | note | |
| 26 | |> cast(attrs, [:note, :project_id]) | |
| 27 | |> validate_required([:note], message: "The message can't be empty") | |
| 28 | 0 | |> assoc_constraint(:project) |
| 29 | end | |
| 30 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Projects.Project do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `Projects` entity, used to label long-running projects. | |
| 3 | ||
| 4 | Projects can be initiated by both customers, as well as in response to supplier | |
| 5 | requirements, and can be linked to a `BusinessPartner` entity. | |
| 6 | ||
| 7 | Timers can also belong to projects, timing disparate activities as part of a | |
| 8 | long-running project. | |
| 9 | """ | |
| 10 | ||
| 11 | use Ecto.Schema | |
| 12 | import Ecto.Changeset | |
| 13 | alias Klepsidra.BusinessPartners.BusinessPartner | |
| 14 | ||
| 15 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 16 | @foreign_key_type Ecto.UUID | |
| 17 | ||
| 18 | @type t :: %__MODULE__{ | |
| 19 | name: String.t(), | |
| 20 | description: String.t(), | |
| 21 | business_partner_id: binary(), | |
| 22 | active: boolean() | |
| 23 | } | |
| 24 | 306 | schema "projects" do |
| 25 | field :name, :string | |
| 26 | field :description, :string | |
| 27 | field :active, :boolean, default: true | |
| 28 | ||
| 29 | belongs_to :business_partner, BusinessPartner, type: Ecto.UUID | |
| 30 | ||
| 31 | many_to_many(:tags, Klepsidra.Categorisation.Tag, | |
| 32 | join_through: "project_tags", | |
| 33 | on_replace: :delete, | |
| 34 | preload_order: [asc: :name] | |
| 35 | ) | |
| 36 | ||
| 37 | timestamps() | |
| 38 | end | |
| 39 | ||
| 40 | @doc false | |
| 41 | def changeset(project, attrs) do | |
| 42 | project | |
| 43 | |> cast(attrs, [:name, :description, :active]) | |
| 44 | |> validate_required([:name], message: "Enter the project name") | |
| 45 | 26 | |> unique_constraint(:name, |
| 46 | name: :projects_name_index, | |
| 47 | message: "A project with this name already exists" | |
| 48 | ) | |
| 49 | end | |
| 50 | ||
| 51 | @doc """ | |
| 52 | Used across live components to populate select options with projects. | |
| 53 | """ | |
| 54 | @spec populate_projects_list() :: [Klepsidra.Projects.Project.t(), ...] | |
| 55 | 2 | def populate_projects_list() do |
| 56 | [ | |
| 57 | {"", ""} | |
| 58 | | Klepsidra.Projects.list_active_projects() | |
| 59 | 0 | |> Enum.map(fn project -> {project.name, project.id} end) |
| 60 | ] | |
| 61 | end | |
| 62 | ||
| 63 | 0 | def projects_list() do |
| 64 | [ | |
| 65 | {"", ""} | |
| 66 | | Klepsidra.Projects.list_active_projects() | |
| 67 | 0 | |> Enum.map(fn project -> {to_string(project.name), to_string(project.id)} end) |
| 68 | ] | |
| 69 | end | |
| 70 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Repo do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use Ecto.Repo, | |
| 4 | otp_app: :klepsidra, | |
| 5 | adapter: Ecto.Adapters.SQLite3 | |
| 6 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.TimeTracking do | |
| 1 | @moduledoc """ | |
| 2 | The TimeTracking context. | |
| 3 | """ | |
| 4 | ||
| 5 | import Ecto.Query, warn: false | |
| 6 | alias Klepsidra.Repo | |
| 7 | alias Klepsidra.TimeTracking.ActivityType | |
| 8 | alias Klepsidra.TimeTracking.Note | |
| 9 | alias Klepsidra.TimeTracking.Timer | |
| 10 | alias Klepsidra.Math | |
| 11 | ||
| 12 | @typedoc """ | |
| 13 | The `timer_record.t()` type is a list of the fields and data types returned in | |
| 14 | timer record queries. | |
| 15 | """ | |
| 16 | @type timer_record :: %{ | |
| 17 | id: binary(), | |
| 18 | start_stamp: binary(), | |
| 19 | end_stamp: binary(), | |
| 20 | duration: integer(), | |
| 21 | duration_time_unit: binary(), | |
| 22 | project_id: nil | binary(), | |
| 23 | project_name: binary(), | |
| 24 | business_partner_id: nil | binary(), | |
| 25 | business_partner_name: binary(), | |
| 26 | billable: boolean(), | |
| 27 | billing_duration: integer(), | |
| 28 | billing_duration_time_unit: binary(), | |
| 29 | billing_rate: Decimal.t(), | |
| 30 | activity_type_id: nil | binary(), | |
| 31 | activity_type: binary(), | |
| 32 | description: binary(), | |
| 33 | inserted_at: NaiveDateTime.t(), | |
| 34 | updated_at: NaiveDateTime.t(), | |
| 35 | modified: integer() | |
| 36 | } | |
| 37 | @typedoc """ | |
| 38 | The `duration.t()` type is a map containing a time duration in several formats: | |
| 39 | ||
| 40 | * The duration as a `Cldr.Unit.t()` type, denominated in the base time increment, `:second` | |
| 41 | * The duration in hours, a basic major unit of the day | |
| 42 | * A human-readable string representing the time converted to days, hours and minutes, | |
| 43 | or nil | |
| 44 | """ | |
| 45 | @type duration :: %{ | |
| 46 | base_unit_duration: Cldr.Unit.t(), | |
| 47 | duration_in_hours: bitstring(), | |
| 48 | human_readable_duration: bitstring() | nil | |
| 49 | } | |
| 50 | @typedoc """ | |
| 51 | When filtering timer queries, a `timer_filter.t()` filter structure will be passed | |
| 52 | with criteria to filter by. The type specifies those fields and their types. | |
| 53 | """ | |
| 54 | @type timer_filter :: %{ | |
| 55 | from: bitstring(), | |
| 56 | to: bitstring(), | |
| 57 | project_id: bitstring(), | |
| 58 | business_partner_id: bitstring(), | |
| 59 | activity_type_id: bitstring(), | |
| 60 | billable: bitstring(), | |
| 61 | modified: binary() | integer() | |
| 62 | } | |
| 63 | ||
| 64 | @doc """ | |
| 65 | Returns the list of timers. | |
| 66 | ||
| 67 | ## Examples | |
| 68 | ||
| 69 | iex> list_timers() | |
| 70 | [%Timer{}, ...] | |
| 71 | ||
| 72 | """ | |
| 73 | def list_timers do | |
| 74 | 1 | Timer |> order_by(desc: :start_stamp) |> Repo.all() |
| 75 | end | |
| 76 | ||
| 77 | @spec list_timers(filter :: map()) :: [map(), ...] | |
| 78 | @doc """ | |
| 79 | Returns a list of timers, filtered by criteria in the `filter` | |
| 80 | parameter. | |
| 81 | ||
| 82 | ## Examples | |
| 83 | ||
| 84 | iex> list_timers(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""}) | |
| 85 | [%{...}] | |
| 86 | """ | |
| 87 | def list_timers(%{modified: modified} = filter) when is_map(filter) do | |
| 88 | list_timers_query(filter) | |
| 89 | |> filter_by_modification_status(%{modified: modified}) | |
| 90 | 0 | |> Repo.all() |
| 91 | end | |
| 92 | ||
| 93 | @doc """ | |
| 94 | ||
| 95 | ## Examples | |
| 96 | ||
| 97 | iex> list_timers_with_statistics(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""}) | |
| 98 | %{meta: %{ | |
| 99 | aggregate_duration: %{ | |
| 100 | duration_in_hours: "4.2 hours", | |
| 101 | base_unit_duration: Cldr.Unit.new!(:second, 15120), | |
| 102 | human_readable_duration: nil}, | |
| 103 | aggregate_billing_duration: %{ | |
| 104 | duration_in_hours: "5.3 hours", | |
| 105 | base_unit_duration: Cldr.Unit.new!(:second, 18900), | |
| 106 | human_readable_duration: nil}}, | |
| 107 | timer_list: [%{...}, ...]} | |
| 108 | """ | |
| 109 | @spec list_timers_with_statistics(filter :: timer_filter()) :: %{ | |
| 110 | timer_list: any(), | |
| 111 | meta: %{ | |
| 112 | timer_count: any(), | |
| 113 | aggregate_duration: any(), | |
| 114 | average_timer_duration: any(), | |
| 115 | aggregate_billing_duration: any() | |
| 116 | } | |
| 117 | } | |
| 118 | def list_timers_with_statistics(filter) when is_map(filter) do | |
| 119 | 0 | timer_count = list_timers_count(filter) |
| 120 | 0 | timer_duration = list_timers_aggregate_duration(filter) |
| 121 | ||
| 122 | 0 | average_timer_duration = |
| 123 | 0 | Math.arithmetic_mean(timer_duration.base_unit_duration, timer_count) |
| 124 | |> Timer.format_aggregate_duration_for_project() | |
| 125 | ||
| 126 | 0 | %{ |
| 127 | timer_list: list_timers(filter), | |
| 128 | meta: %{ | |
| 129 | timer_count: timer_count, | |
| 130 | aggregate_duration: timer_duration, | |
| 131 | average_timer_duration: average_timer_duration, | |
| 132 | aggregate_billing_duration: list_timers_aggregate_billing_duration(filter) | |
| 133 | } | |
| 134 | } | |
| 135 | end | |
| 136 | ||
| 137 | @doc """ | |
| 138 | ||
| 139 | ## Examples | |
| 140 | ||
| 141 | iex> list_timers_count(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""}) | |
| 142 | 9 | |
| 143 | """ | |
| 144 | @spec list_timers_count(filter :: map()) :: non_neg_integer() | |
| 145 | def list_timers_count(%{modified: modified} = filter) when is_map(filter) do | |
| 146 | list_timers_query(filter) | |
| 147 | |> filter_by_modification_status(%{modified: modified}) | |
| 148 | 0 | |> select([at], count(at.id)) |
| 149 | 0 | |> Repo.one() |
| 150 | end | |
| 151 | ||
| 152 | @doc """ | |
| 153 | ||
| 154 | ## Examples | |
| 155 | ||
| 156 | iex> list_timers_aggregate_duration(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""}) | |
| 157 | %{ | |
| 158 | duration_in_hours: "4.2 hours", | |
| 159 | base_unit_duration: Cldr.Unit.new!(:second, 15120), | |
| 160 | human_readable_duration: nil | |
| 161 | } | |
| 162 | """ | |
| 163 | @spec list_timers_aggregate_duration(filter :: map()) :: duration() | |
| 164 | def list_timers_aggregate_duration(filter) when is_map(filter) do | |
| 165 | list_timers_query(filter) | |
| 166 | |> select([at], {sum(at.duration), at.duration_time_unit}) | |
| 167 | 0 | |> group_by([at], at.duration_time_unit) |
| 168 | |> Repo.all() | |
| 169 | 0 | |> Timer.calculate_aggregate_duration_for_timers() |
| 170 | end | |
| 171 | ||
| 172 | @doc """ | |
| 173 | ||
| 174 | ## Examples | |
| 175 | ||
| 176 | iex> list_timers_aggregate_billing_duration(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""}) | |
| 177 | %{ | |
| 178 | duration_in_hours: "5.3 hours", | |
| 179 | base_unit_duration: Cldr.Unit.new!(:second, 18900), | |
| 180 | human_readable_duration: nil | |
| 181 | } | |
| 182 | """ | |
| 183 | @spec list_timers_aggregate_billing_duration(filter :: map()) :: %{ | |
| 184 | base_unit_duration: Cldr.Unit.t(), | |
| 185 | duration_in_hours: bitstring(), | |
| 186 | human_readable_duration: bitstring() | nil | |
| 187 | } | |
| 188 | def list_timers_aggregate_billing_duration(filter) when is_map(filter) do | |
| 189 | list_timers_query(filter) | |
| 190 | |> select([at], {sum(at.billing_duration), at.billing_duration_time_unit}) | |
| 191 | 0 | |> group_by([at], at.billing_duration_time_unit) |
| 192 | |> Repo.all() | |
| 193 | 0 | |> Timer.calculate_aggregate_duration_for_timers() |
| 194 | end | |
| 195 | ||
| 196 | defp list_timers_query(filter) when is_map(filter) do | |
| 197 | %{ | |
| 198 | from: from, | |
| 199 | to: to, | |
| 200 | project_id: project_id, | |
| 201 | business_partner_id: business_partner_id, | |
| 202 | activity_type_id: activity_type_id, | |
| 203 | billable: billable | |
| 204 | 0 | } = |
| 205 | filter | |
| 206 | ||
| 207 | 0 | order_by = [asc: :inserted_at] |
| 208 | ||
| 209 | 0 | query = |
| 210 | 0 | from( |
| 211 | at in Timer, | |
| 212 | left_join: p in assoc(at, :project), | |
| 213 | left_join: bp in assoc(at, :business_partner), | |
| 214 | left_join: act in assoc(at, :activity_type), | |
| 215 | where: not is_nil(at.end_stamp), | |
| 216 | order_by: ^order_by, | |
| 217 | select: %{ | |
| 218 | id: at.id, | |
| 219 | start_stamp: at.start_stamp, | |
| 220 | end_stamp: at.end_stamp, | |
| 221 | duration: at.duration, | |
| 222 | duration_time_unit: at.duration_time_unit, | |
| 223 | project_id: p.id, | |
| 224 | project_name: p.name |> coalesce(""), | |
| 225 | business_partner_id: at.business_partner_id, | |
| 226 | business_partner_name: bp.name |> coalesce(""), | |
| 227 | billable: at.billable, | |
| 228 | billing_duration: at.billing_duration, | |
| 229 | billing_duration_time_unit: at.billing_duration_time_unit, | |
| 230 | billing_rate: at.billing_rate, | |
| 231 | activity_type_id: act.id, | |
| 232 | activity_type: act.name |> coalesce(""), | |
| 233 | description: at.description |> coalesce(""), | |
| 234 | inserted_at: at.inserted_at, | |
| 235 | updated_at: at.updated_at, | |
| 236 | modified: fragment("iif(? != ?, true, false)", at.inserted_at, at.updated_at) | |
| 237 | } | |
| 238 | ) | |
| 239 | ||
| 240 | 0 | timer_subquery = |
| 241 | query | |
| 242 | |> filter_by_date(%{from: from, to: to}) | |
| 243 | |> filter_by_project_id(%{project_id: project_id}) | |
| 244 | |> filter_by_business_partner_id(%{business_partner_id: business_partner_id}) | |
| 245 | |> filter_by_activity_type_id(%{activity_type_id: activity_type_id}) | |
| 246 | |> filter_by_billable(%{billable: billable}) | |
| 247 | ||
| 248 | 0 | from(at in subquery(timer_subquery)) |
| 249 | end | |
| 250 | ||
| 251 | 0 | defp filter_by_date(query, %{from: "", to: ""}), do: query |
| 252 | ||
| 253 | defp filter_by_date(query, %{from: from, to: ""}) do | |
| 254 | 0 | where(query, [at], at.start_stamp >= ^from) |
| 255 | end | |
| 256 | ||
| 257 | defp filter_by_date(query, %{from: "", to: to}) do | |
| 258 | 0 | where(query, [at], at.end_stamp <= ^to) |
| 259 | end | |
| 260 | ||
| 261 | defp filter_by_date(query, %{from: from, to: to}) do | |
| 262 | query | |
| 263 | |> where([at], at.start_stamp >= ^from) | |
| 264 | 0 | |> where([at], at.end_stamp <= ^to) |
| 265 | end | |
| 266 | ||
| 267 | 0 | defp filter_by_project_id(query, %{project_id: ""}), do: query |
| 268 | ||
| 269 | defp filter_by_project_id(query, %{project_id: project_id}) do | |
| 270 | query | |
| 271 | 0 | |> where([at], at.project_id == ^project_id) |
| 272 | end | |
| 273 | ||
| 274 | 0 | defp filter_by_business_partner_id(query, %{business_partner_id: ""}), do: query |
| 275 | ||
| 276 | defp filter_by_business_partner_id(query, %{business_partner_id: business_partner_id}) do | |
| 277 | query | |
| 278 | 0 | |> where([at], at.business_partner_id == ^business_partner_id) |
| 279 | end | |
| 280 | ||
| 281 | 0 | defp filter_by_activity_type_id(query, %{activity_type_id: ""}), do: query |
| 282 | ||
| 283 | defp filter_by_activity_type_id(query, %{activity_type_id: activity_type_id}) do | |
| 284 | query | |
| 285 | 0 | |> where([at], at.activity_type_id == ^activity_type_id) |
| 286 | end | |
| 287 | ||
| 288 | 0 | defp filter_by_billable(query, %{billable: ""}), do: query |
| 289 | ||
| 290 | defp filter_by_billable(query, %{billable: billable}) do | |
| 291 | query | |
| 292 | 0 | |> where([at], at.billable == ^billable) |
| 293 | end | |
| 294 | ||
| 295 | 0 | defp filter_by_modification_status(query, %{modified: ""}), do: query |
| 296 | ||
| 297 | defp filter_by_modification_status(query, %{modified: modified}) when is_bitstring(modified) do | |
| 298 | query | |
| 299 | 0 | |> where([at], at.modified == ^String.to_integer(modified)) |
| 300 | end | |
| 301 | ||
| 302 | defp filter_by_modification_status(query, %{modified: modified}) when is_integer(modified) do | |
| 303 | query | |
| 304 | 0 | |> where([at], at.modified == ^modified) |
| 305 | end | |
| 306 | ||
| 307 | @doc """ | |
| 308 | Returns the list of timers, along with the associated tags. | |
| 309 | ||
| 310 | ## Examples | |
| 311 | ||
| 312 | iex> list_timers_with_tags() | |
| 313 | [%Timer{}, ...] | |
| 314 | ||
| 315 | """ | |
| 316 | def list_timers_with_tags do | |
| 317 | 0 | query = |
| 318 | from( | |
| 319 | at in Timer, | |
| 320 | order_by: [desc: :start_stamp], | |
| 321 | preload: :tags | |
| 322 | ) | |
| 323 | ||
| 324 | 0 | Repo.all(query) |
| 325 | end | |
| 326 | ||
| 327 | @doc """ | |
| 328 | Returns the list of timers, with `business_partner` association preloaded. | |
| 329 | ||
| 330 | ## Examples | |
| 331 | ||
| 332 | iex> list_timers() | |
| 333 | [%Timer{}, ...] | |
| 334 | ||
| 335 | """ | |
| 336 | def list_timers_with_customers do | |
| 337 | 8 | query = |
| 338 | 8 | from( |
| 339 | at in Timer, | |
| 340 | left_join: bp in assoc(at, :business_partner), | |
| 341 | left_join: p in assoc(at, :project), | |
| 342 | order_by: [desc: at.inserted_at, asc: at.id], | |
| 343 | select: %{ | |
| 344 | id: at.id, | |
| 345 | start_stamp: at.start_stamp, | |
| 346 | end_stamp: at.end_stamp, | |
| 347 | duration: at.duration, | |
| 348 | duration_time_unit: at.duration_time_unit, | |
| 349 | billing_duration: at.billing_duration, | |
| 350 | billing_duration_time_unit: at.billing_duration_time_unit, | |
| 351 | description: at.description |> coalesce(""), | |
| 352 | project_name: p.name |> coalesce(""), | |
| 353 | business_partner_id: at.business_partner_id, | |
| 354 | business_partner_name: bp.name |> coalesce(""), | |
| 355 | inserted_at: at.inserted_at | |
| 356 | } | |
| 357 | ) | |
| 358 | ||
| 359 | query | |
| 360 | |> Repo.all() | |
| 361 | 8 | |> Enum.map(fn rec -> |
| 362 | 8 | Map.merge(rec, %{ |
| 363 | start_stamp: | |
| 364 | 8 | Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)), |
| 365 | end_stamp: | |
| 366 | 8 | if(rec.end_stamp, |
| 367 | 8 | do: Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.end_stamp)) |
| 368 | ), | |
| 369 | summary: | |
| 370 | 8 | rec.description |
| 371 | |> markdown_to_html() | |
| 372 | |> Phoenix.HTML.raw(), | |
| 373 | formatted_start_date: nil, | |
| 374 | formatted_duration: | |
| 375 | 8 | {rec.duration, rec.duration_time_unit} |
| 376 | |> Timer.convert_duration_to_base_time_unit() | |
| 377 | |> Klepsidra.TimeTracking.Timer.format_human_readable_duration() | |
| 378 | }) | |
| 379 | end) | |
| 380 | end | |
| 381 | ||
| 382 | @doc """ | |
| 383 | Gets a single timer. | |
| 384 | ||
| 385 | Raises `Ecto.NoResultsError` if the Timer does not exist. | |
| 386 | ||
| 387 | ## Examples | |
| 388 | ||
| 389 | iex> get_timer!(123) | |
| 390 | %Timer{} | |
| 391 | ||
| 392 | iex> get_timer!(456) | |
| 393 | ** (Ecto.NoResultsError) | |
| 394 | ||
| 395 | """ | |
| 396 | 9 | def get_timer!(id), do: Repo.get!(Timer, id) |
| 397 | ||
| 398 | @doc """ | |
| 399 | Gets a single timer, with its `business_partner` association preloaded. | |
| 400 | ||
| 401 | Raises `Ecto.NoResultsError` if the Timer does not exist. | |
| 402 | ||
| 403 | ## Examples | |
| 404 | ||
| 405 | iex> get_timer!(123) | |
| 406 | %Timer{} | |
| 407 | ||
| 408 | iex> get_timer!(456) | |
| 409 | ** (Ecto.NoResultsError) | |
| 410 | """ | |
| 411 | def get_formatted_timer_record!(id) do | |
| 412 | 0 | query = |
| 413 | 0 | from(at in Timer, |
| 414 | where: at.id == ^id, | |
| 415 | left_join: p in assoc(at, :project), | |
| 416 | left_join: bp in assoc(at, :business_partner), | |
| 417 | left_join: act in assoc(at, :activity_type), | |
| 418 | select: %{ | |
| 419 | id: at.id, | |
| 420 | start_stamp: at.start_stamp, | |
| 421 | end_stamp: at.end_stamp, | |
| 422 | duration: at.duration, | |
| 423 | duration_time_unit: at.duration_time_unit, | |
| 424 | project_id: p.id, | |
| 425 | project_name: p.name |> coalesce(""), | |
| 426 | business_partner_id: at.business_partner_id, | |
| 427 | business_partner_name: bp.name |> coalesce(""), | |
| 428 | billable: at.billable, | |
| 429 | billing_duration: at.billing_duration, | |
| 430 | billing_duration_time_unit: at.billing_duration_time_unit, | |
| 431 | billing_rate: at.billing_rate, | |
| 432 | activity_type_id: act.id, | |
| 433 | activity_type: act.name |> coalesce(""), | |
| 434 | description: at.description |> coalesce(""), | |
| 435 | inserted_at: at.inserted_at | |
| 436 | } | |
| 437 | ) | |
| 438 | ||
| 439 | query | |
| 440 | |> Repo.all() | |
| 441 | |> Enum.map(fn rec -> | |
| 442 | 0 | Map.merge(rec, %{ |
| 443 | start_stamp: | |
| 444 | 0 | Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)), |
| 445 | end_stamp: | |
| 446 | 0 | if(rec.end_stamp, |
| 447 | 0 | do: Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.end_stamp)) |
| 448 | ), | |
| 449 | summary: | |
| 450 | 0 | rec.description |
| 451 | |> markdown_to_html() | |
| 452 | |> Phoenix.HTML.raw(), | |
| 453 | formatted_start_date: "", | |
| 454 | formatted_duration: | |
| 455 | 0 | {rec.duration, rec.duration_time_unit} |
| 456 | |> Timer.convert_duration_to_base_time_unit() | |
| 457 | |> Klepsidra.TimeTracking.Timer.format_human_readable_duration() | |
| 458 | }) | |
| 459 | end) | |
| 460 | 0 | |> List.first() |
| 461 | end | |
| 462 | ||
| 463 | @doc """ | |
| 464 | Gets a list of closed timers started on the specified date. | |
| 465 | ||
| 466 | A closed timer is one which has an end datetime stamp recorded, as well as | |
| 467 | a starting one. | |
| 468 | """ | |
| 469 | @spec get_closed_timers_for_date(NaiveDateTime.t()) :: | |
| 470 | [Klepsidra.TimeTracking.Timer.t(), ...] | [] | |
| 471 | def get_closed_timers_for_date(date) when is_struct(date, NaiveDateTime) do | |
| 472 | 0 | start_of_day = NaiveDateTime.beginning_of_day(date) |
| 473 | 0 | end_of_day = NaiveDateTime.add(start_of_day, 24, :hour) |
| 474 | ||
| 475 | 0 | query = |
| 476 | 0 | from( |
| 477 | at in Timer, | |
| 478 | left_join: bp in assoc(at, :business_partner), | |
| 479 | left_join: p in assoc(at, :project), | |
| 480 | where: | |
| 481 | at.start_stamp <= type(^end_of_day, :naive_datetime) and | |
| 482 | at.end_stamp >= type(^start_of_day, :naive_datetime) and | |
| 483 | not is_nil(at.end_stamp), | |
| 484 | order_by: [desc: at.inserted_at, asc: at.id], | |
| 485 | select: %{ | |
| 486 | id: at.id, | |
| 487 | start_stamp: at.start_stamp, | |
| 488 | end_stamp: at.end_stamp, | |
| 489 | duration: at.duration, | |
| 490 | duration_time_unit: at.duration_time_unit, | |
| 491 | description: at.description |> coalesce(""), | |
| 492 | project_name: p.name |> coalesce(""), | |
| 493 | business_partner_id: at.business_partner_id, | |
| 494 | business_partner_name: bp.name |> coalesce(""), | |
| 495 | inserted_at: at.inserted_at | |
| 496 | } | |
| 497 | ) | |
| 498 | ||
| 499 | query | |
| 500 | |> Repo.all() | |
| 501 | 0 | |> Enum.map(fn rec -> |
| 502 | 0 | Map.merge(rec, %{ |
| 503 | start_stamp: | |
| 504 | 0 | Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)), |
| 505 | 0 | end_stamp: Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.end_stamp)), |
| 506 | summary: | |
| 507 | 0 | rec.description |
| 508 | |> markdown_to_html() | |
| 509 | |> Phoenix.HTML.raw(), | |
| 510 | formatted_start_date: | |
| 511 | 0 | if( |
| 512 | 0 | NaiveDateTime.compare(Timer.parse_html_datetime!(rec.start_stamp), start_of_day) == |
| 513 | :lt, | |
| 514 | do: "Started yesterday", | |
| 515 | else: "" | |
| 516 | ), | |
| 517 | formatted_duration: | |
| 518 | 0 | {rec.duration, rec.duration_time_unit} |
| 519 | |> Timer.convert_duration_to_base_time_unit() | |
| 520 | |> Klepsidra.TimeTracking.Timer.format_human_readable_duration() | |
| 521 | }) | |
| 522 | end) | |
| 523 | end | |
| 524 | ||
| 525 | @doc """ | |
| 526 | """ | |
| 527 | def truncate(text, opts) do | |
| 528 | 0 | max_length = opts[:max_length] || 59 |
| 529 | 0 | omission = opts[:omission] || "..." |
| 530 | ||
| 531 | 0 | cond do |
| 532 | not String.valid?(text) -> | |
| 533 | 0 | text |
| 534 | ||
| 535 | 0 | String.length(text) < max_length -> |
| 536 | 0 | text |
| 537 | ||
| 538 | 0 | true -> |
| 539 | 0 | length_with_omission = max_length - String.length(omission) |
| 540 | ||
| 541 | 0 | "#{String.slice(text, 0, length_with_omission)}#{omission}" |
| 542 | end | |
| 543 | end | |
| 544 | ||
| 545 | @doc """ | |
| 546 | """ | |
| 547 | def markdown_to_html(markdown, _options \\ []) do | |
| 548 | markdown | |
| 549 | |> Earmark.as_html!( | |
| 550 | compact_output: true, | |
| 551 | code_class_prefix: "lang-", | |
| 552 | smartypants: true | |
| 553 | ) | |
| 554 | 8 | |> HtmlSanitizeEx.html5() |
| 555 | end | |
| 556 | ||
| 557 | @doc """ | |
| 558 | Gets a count of closed timers started on the specified date. | |
| 559 | ||
| 560 | A closed timer is one which has an end datetime stamp recorded, as well as | |
| 561 | a starting one. | |
| 562 | """ | |
| 563 | @spec get_closed_timer_count_for_date(NaiveDateTime.t()) :: integer() | |
| 564 | def get_closed_timer_count_for_date(date) when is_struct(date, NaiveDateTime) do | |
| 565 | 0 | start_of_day = NaiveDateTime.beginning_of_day(date) |
| 566 | 0 | end_of_day = NaiveDateTime.add(start_of_day, 24, :hour) |
| 567 | ||
| 568 | 0 | query = |
| 569 | from( | |
| 570 | at in "timers", | |
| 571 | select: count(at.id), | |
| 572 | where: | |
| 573 | at.start_stamp <= type(^end_of_day, :naive_datetime) and | |
| 574 | at.end_stamp >= type(^start_of_day, :naive_datetime) and | |
| 575 | not is_nil(at.end_stamp) | |
| 576 | ) | |
| 577 | ||
| 578 | 0 | Repo.one(query) |
| 579 | end | |
| 580 | ||
| 581 | @doc """ | |
| 582 | Gets a count of all open timers. | |
| 583 | ||
| 584 | An open timer is one without an end datetime stamp recorded, as well as | |
| 585 | a starting one. | |
| 586 | """ | |
| 587 | @spec get_open_timer_count() :: integer() | |
| 588 | def get_open_timer_count() do | |
| 589 | 0 | query = |
| 590 | from( | |
| 591 | at in "timers", | |
| 592 | select: count(at.id), | |
| 593 | where: | |
| 594 | not is_nil(at.start_stamp) and | |
| 595 | is_nil(at.end_stamp) | |
| 596 | ) | |
| 597 | ||
| 598 | 0 | Repo.one(query) |
| 599 | end | |
| 600 | ||
| 601 | @doc """ | |
| 602 | Gets a sum of timer durations for the specified date, by time unit. | |
| 603 | ||
| 604 | A closed timer is one which has an end datetime stamp recorded, as well as | |
| 605 | a starting one. | |
| 606 | """ | |
| 607 | @spec get_closed_timer_durations_for_date(NaiveDateTime.t()) :: | |
| 608 | [{integer, bitstring()}, ...] | [] | |
| 609 | def get_closed_timer_durations_for_date(date) when is_struct(date, NaiveDateTime) do | |
| 610 | 0 | start_of_day = NaiveDateTime.beginning_of_day(date) |
| 611 | 0 | end_of_day = NaiveDateTime.add(start_of_day, 24, :hour) |
| 612 | ||
| 613 | 0 | query = |
| 614 | from( | |
| 615 | at in "timers", | |
| 616 | select: {sum(at.duration), at.duration_time_unit}, | |
| 617 | group_by: at.duration_time_unit, | |
| 618 | where: | |
| 619 | at.start_stamp <= type(^end_of_day, :naive_datetime) and | |
| 620 | at.end_stamp >= type(^start_of_day, :naive_datetime) and | |
| 621 | not is_nil(at.end_stamp) | |
| 622 | ) | |
| 623 | ||
| 624 | 0 | Repo.all(query) |
| 625 | end | |
| 626 | ||
| 627 | @doc """ | |
| 628 | Gets a sum of timer durations for the specified project, by time unit. | |
| 629 | ||
| 630 | A closed timer is one which has an end datetime stamp recorded, as well as | |
| 631 | a starting one. | |
| 632 | """ | |
| 633 | @spec get_closed_timer_durations_for_project(bitstring()) :: | |
| 634 | [{integer, bitstring()}, ...] | [] | |
| 635 | def get_closed_timer_durations_for_project(project_id) | |
| 636 | when is_bitstring(project_id) do | |
| 637 | 6 | query = |
| 638 | from( | |
| 639 | at in "timers", | |
| 640 | select: {sum(at.duration), at.duration_time_unit}, | |
| 641 | group_by: at.duration_time_unit, | |
| 642 | where: | |
| 643 | not is_nil(at.start_stamp) and | |
| 644 | not is_nil(at.end_stamp) and | |
| 645 | at.project_id == ^project_id | |
| 646 | ) | |
| 647 | ||
| 648 | 6 | Repo.all(query) |
| 649 | end | |
| 650 | ||
| 651 | @doc """ | |
| 652 | Gets a list of all open timers. | |
| 653 | ||
| 654 | A timer is considered open if it has no `end_stamp`. | |
| 655 | """ | |
| 656 | @spec get_all_open_timers() :: [Klepsidra.TimeTracking.Timer.t(), ...] | [] | |
| 657 | def get_all_open_timers() do | |
| 658 | 0 | query = |
| 659 | 0 | from( |
| 660 | at in Timer, | |
| 661 | left_join: bp in assoc(at, :business_partner), | |
| 662 | left_join: p in assoc(at, :project), | |
| 663 | select: %{ | |
| 664 | id: at.id, | |
| 665 | start_stamp: at.start_stamp, | |
| 666 | end_stamp: at.end_stamp, | |
| 667 | duration: at.duration, | |
| 668 | duration_time_unit: at.duration_time_unit, | |
| 669 | description: at.description |> coalesce(""), | |
| 670 | project_name: p.name |> coalesce(""), | |
| 671 | business_partner_id: at.business_partner_id, | |
| 672 | business_partner_name: bp.name |> coalesce(""), | |
| 673 | inserted_at: at.inserted_at | |
| 674 | }, | |
| 675 | where: | |
| 676 | not is_nil(at.start_stamp) and | |
| 677 | is_nil(at.end_stamp), | |
| 678 | order_by: [desc: at.start_stamp, desc: at.inserted_at] | |
| 679 | ) | |
| 680 | ||
| 681 | query | |
| 682 | |> Repo.all() | |
| 683 | 0 | |> Enum.map(fn rec -> |
| 684 | 0 | Map.merge(rec, %{ |
| 685 | start_stamp: | |
| 686 | 0 | Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)), |
| 687 | end_stamp: nil, | |
| 688 | formatted_start_date: | |
| 689 | Timex.from_now( | |
| 690 | 0 | Timer.parse_html_datetime!(rec.start_stamp), |
| 691 | NaiveDateTime.local_now() | |
| 692 | ), | |
| 693 | summary: | |
| 694 | 0 | rec.description |
| 695 | 0 | |> to_string() |
| 696 | |> markdown_to_html() | |
| 697 | |> Phoenix.HTML.raw() | |
| 698 | }) | |
| 699 | end) | |
| 700 | end | |
| 701 | ||
| 702 | @doc """ | |
| 703 | Creates a timer. | |
| 704 | ||
| 705 | ## Examples | |
| 706 | ||
| 707 | iex> create_timer(%{field: value}) | |
| 708 | {:ok, %Timer{}} | |
| 709 | ||
| 710 | iex> create_timer(%{field: bad_value}) | |
| 711 | {:error, %Ecto.Changeset{}} | |
| 712 | ||
| 713 | """ | |
| 714 | def create_timer(attrs \\ %{}) do | |
| 715 | %Timer{} | |
| 716 | |> Timer.changeset(attrs) | |
| 717 | 13 | |> Repo.insert() |
| 718 | end | |
| 719 | ||
| 720 | @doc """ | |
| 721 | Updates a timer. | |
| 722 | ||
| 723 | ## Examples | |
| 724 | ||
| 725 | iex> update_timer(timer, %{field: new_value}) | |
| 726 | {:ok, %Timer{}} | |
| 727 | ||
| 728 | iex> update_timer(timer, %{field: bad_value}) | |
| 729 | {:error, %Ecto.Changeset{}} | |
| 730 | ||
| 731 | """ | |
| 732 | def update_timer(%Timer{} = timer, attrs) do | |
| 733 | timer | |
| 734 | |> Timer.changeset(attrs) | |
| 735 | 3 | |> Repo.update() |
| 736 | end | |
| 737 | ||
| 738 | @doc """ | |
| 739 | Deletes a timer. | |
| 740 | ||
| 741 | ## Examples | |
| 742 | ||
| 743 | iex> delete_timer(timer) | |
| 744 | {:ok, %Timer{}} | |
| 745 | ||
| 746 | iex> delete_timer(timer) | |
| 747 | {:error, %Ecto.Changeset{}} | |
| 748 | ||
| 749 | """ | |
| 750 | def delete_timer(%Timer{} = timer) do | |
| 751 | 2 | Repo.delete(timer) |
| 752 | end | |
| 753 | ||
| 754 | @doc """ | |
| 755 | Returns an `%Ecto.Changeset{}` for tracking timer changes. | |
| 756 | ||
| 757 | ## Examples | |
| 758 | ||
| 759 | iex> change_timer(timer) | |
| 760 | %Ecto.Changeset{data: %Timer{}} | |
| 761 | ||
| 762 | """ | |
| 763 | def change_timer(%Timer{} = timer, attrs \\ %{}) do | |
| 764 | 3 | Timer.changeset(timer, attrs) |
| 765 | end | |
| 766 | ||
| 767 | @doc """ | |
| 768 | Returns the list of notes. | |
| 769 | ||
| 770 | ## Examples | |
| 771 | ||
| 772 | iex> list_notes() | |
| 773 | [%Note{}, ...] | |
| 774 | ||
| 775 | """ | |
| 776 | def list_notes do | |
| 777 | 0 | Repo.all(Note) |
| 778 | end | |
| 779 | ||
| 780 | @doc """ | |
| 781 | Returns a list of notes matching the given `filter`. | |
| 782 | ||
| 783 | Example filter: | |
| 784 | ||
| 785 | %{timer_id: 42} | |
| 786 | """ | |
| 787 | 0 | def list_notes(filter) when is_map(filter) do |
| 788 | # from(Note) | |
| 789 | # |> filter_notes_by_timer(filter) | |
| 790 | # |> Repo.all() | |
| 791 | end | |
| 792 | ||
| 793 | @doc """ | |
| 794 | Gets a single note. | |
| 795 | ||
| 796 | Raises `Ecto.NoResultsError` if the Note does not exist. | |
| 797 | ||
| 798 | ## Examples | |
| 799 | ||
| 800 | iex> get_note!(123) | |
| 801 | %Note{} | |
| 802 | ||
| 803 | iex> get_note!(456) | |
| 804 | ** (Ecto.NoResultsError) | |
| 805 | ||
| 806 | """ | |
| 807 | 0 | def get_note!(id), do: Repo.get!(Note, id) |
| 808 | ||
| 809 | @doc false | |
| 810 | def get_note_by_timer_id!(timer_id) do | |
| 811 | Note | |
| 812 | |> where(timer_id: ^timer_id) | |
| 813 | 2 | |> order_by(desc: :inserted_at) |
| 814 | 2 | |> Repo.all() |
| 815 | end | |
| 816 | ||
| 817 | @doc """ | |
| 818 | Creates a note. | |
| 819 | ||
| 820 | ## Examples | |
| 821 | ||
| 822 | iex> create_note(%{field: value}) | |
| 823 | {:ok, %Note{}} | |
| 824 | ||
| 825 | iex> create_note(%{field: bad_value}) | |
| 826 | {:error, %Ecto.Changeset{}} | |
| 827 | ||
| 828 | """ | |
| 829 | def create_note(attrs \\ %{}) do | |
| 830 | %Note{} | |
| 831 | |> Note.changeset(attrs) | |
| 832 | 1 | |> Repo.insert() |
| 833 | end | |
| 834 | ||
| 835 | @doc """ | |
| 836 | Updates a note. | |
| 837 | ||
| 838 | ## Examples | |
| 839 | ||
| 840 | iex> update_note(note, %{field: new_value}) | |
| 841 | {:ok, %Note{}} | |
| 842 | ||
| 843 | iex> update_note(note, %{field: bad_value}) | |
| 844 | {:error, %Ecto.Changeset{}} | |
| 845 | ||
| 846 | """ | |
| 847 | def update_note(%Note{} = note, attrs) do | |
| 848 | note | |
| 849 | |> Note.changeset(attrs) | |
| 850 | 0 | |> Repo.update() |
| 851 | end | |
| 852 | ||
| 853 | @doc """ | |
| 854 | Deletes a note. | |
| 855 | ||
| 856 | ## Examples | |
| 857 | ||
| 858 | iex> delete_note(note) | |
| 859 | {:ok, %Note{}} | |
| 860 | ||
| 861 | iex> delete_note(note) | |
| 862 | {:error, %Ecto.Changeset{}} | |
| 863 | ||
| 864 | """ | |
| 865 | def delete_note(%Note{} = note) do | |
| 866 | 0 | Repo.delete(note) |
| 867 | end | |
| 868 | ||
| 869 | @doc """ | |
| 870 | Returns an `%Ecto.Changeset{}` for tracking note changes. | |
| 871 | ||
| 872 | ## Examples | |
| 873 | ||
| 874 | iex> change_note(note) | |
| 875 | %Ecto.Changeset{data: %Note{}} | |
| 876 | ||
| 877 | """ | |
| 878 | def change_note(%Note{} = note, attrs \\ %{}) do | |
| 879 | 2 | Note.changeset(note, attrs) |
| 880 | end | |
| 881 | ||
| 882 | @doc """ | |
| 883 | Returns the list of activity_types. | |
| 884 | ||
| 885 | ## Examples | |
| 886 | ||
| 887 | iex> list_activity_types() | |
| 888 | [%ActivityType{}, ...] | |
| 889 | ||
| 890 | """ | |
| 891 | def list_activity_types do | |
| 892 | 9 | ActivityType |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all() |
| 893 | end | |
| 894 | ||
| 895 | @doc """ | |
| 896 | Returns the list of active activity_types. | |
| 897 | ||
| 898 | ## Examples | |
| 899 | ||
| 900 | iex> list_active_activity_types() | |
| 901 | [%ActivityType{}, ...] | |
| 902 | ||
| 903 | """ | |
| 904 | def list_active_activity_types do | |
| 905 | ActivityType | |
| 906 | |> where(active: true) | |
| 907 | 0 | |> order_by(asc: fragment("name COLLATE NOCASE")) |
| 908 | 0 | |> Repo.all() |
| 909 | end | |
| 910 | ||
| 911 | @doc """ | |
| 912 | Gets a single activity_type. | |
| 913 | ||
| 914 | Raises `Ecto.NoResultsError` if the Activity type does not exist. | |
| 915 | ||
| 916 | ## Examples | |
| 917 | ||
| 918 | iex> get_activity_type!(123) | |
| 919 | %ActivityType{} | |
| 920 | ||
| 921 | iex> get_activity_type!(456) | |
| 922 | ** (Ecto.NoResultsError) | |
| 923 | ||
| 924 | """ | |
| 925 | 11 | def get_activity_type!(id), do: Repo.get!(ActivityType, id) |
| 926 | ||
| 927 | @doc """ | |
| 928 | Creates a activity_type. | |
| 929 | ||
| 930 | ## Examples | |
| 931 | ||
| 932 | iex> create_activity_type(%{field: value}) | |
| 933 | {:ok, %ActivityType{}} | |
| 934 | ||
| 935 | iex> create_activity_type(%{field: bad_value}) | |
| 936 | {:error, %Ecto.Changeset{}} | |
| 937 | ||
| 938 | """ | |
| 939 | def create_activity_type(attrs \\ %{}) do | |
| 940 | %ActivityType{} | |
| 941 | |> ActivityType.changeset(attrs) | |
| 942 | 15 | |> Repo.insert() |
| 943 | end | |
| 944 | ||
| 945 | @doc """ | |
| 946 | Updates a activity_type. | |
| 947 | ||
| 948 | ## Examples | |
| 949 | ||
| 950 | iex> update_activity_type(activity_type, %{field: new_value}) | |
| 951 | {:ok, %ActivityType{}} | |
| 952 | ||
| 953 | iex> update_activity_type(activity_type, %{field: bad_value}) | |
| 954 | {:error, %Ecto.Changeset{}} | |
| 955 | ||
| 956 | """ | |
| 957 | def update_activity_type(%ActivityType{} = activity_type, attrs) do | |
| 958 | activity_type | |
| 959 | |> ActivityType.changeset(attrs) | |
| 960 | 4 | |> Repo.update() |
| 961 | end | |
| 962 | ||
| 963 | @doc """ | |
| 964 | Deletes an activity_type. | |
| 965 | ||
| 966 | ## Examples | |
| 967 | ||
| 968 | iex> delete_activity_type(activity_type) | |
| 969 | {:ok, %ActivityType{}} | |
| 970 | ||
| 971 | iex> delete_activity_type(activity_type) | |
| 972 | {:error, %Ecto.Changeset{}} | |
| 973 | ||
| 974 | """ | |
| 975 | def delete_activity_type(%ActivityType{} = activity_type) do | |
| 976 | 2 | Repo.delete(activity_type) |
| 977 | end | |
| 978 | ||
| 979 | @doc """ | |
| 980 | Returns an `%Ecto.Changeset{}` for tracking activity_type changes. | |
| 981 | ||
| 982 | ## Examples | |
| 983 | ||
| 984 | iex> change_activity_type(activity_type) | |
| 985 | %Ecto.Changeset{data: %ActivityType{}} | |
| 986 | ||
| 987 | """ | |
| 988 | def change_activity_type(%ActivityType{} = activity_type, attrs \\ %{}) do | |
| 989 | 7 | ActivityType.changeset(activity_type, attrs) |
| 990 | end | |
| 991 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.TimeTracking.ActivityType do | |
| 1 | @moduledoc """ | |
| 2 | Defines a schema for the `ActivityType` entity, used to set activity types on timers. | |
| 3 | ||
| 4 | Activity types are a way to preload billing defaults, to help calculate | |
| 5 | billing amounts at time of invoicing. | |
| 6 | """ | |
| 7 | ||
| 8 | use Ecto.Schema | |
| 9 | import Ecto.Changeset | |
| 10 | ||
| 11 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 12 | @foreign_key_type Ecto.UUID | |
| 13 | ||
| 14 | @type t :: %__MODULE__{ | |
| 15 | name: String.t(), | |
| 16 | billing_rate: number(), | |
| 17 | active: boolean() | |
| 18 | } | |
| 19 | 269 | schema "activity_types" do |
| 20 | field :name, :string | |
| 21 | field :billing_rate, :decimal | |
| 22 | field :active, :boolean, default: true | |
| 23 | ||
| 24 | timestamps() | |
| 25 | end | |
| 26 | ||
| 27 | @doc false | |
| 28 | def changeset(activity_type, attrs) do | |
| 29 | activity_type | |
| 30 | |> cast(attrs, [:name, :billing_rate, :active]) | |
| 31 | |> validate_required([:name], message: "Enter an activity type name") | |
| 32 | |> unique_constraint(:name, message: "An activity type with this name already exists") | |
| 33 | |> validate_required([:billing_rate], message: "The hourly billing rate must be a number") | |
| 34 | 26 | |> validate_number(:billing_rate, |
| 35 | greater_than_or_equal_to: 0, | |
| 36 | message: "The billing rate must be zero or greater" | |
| 37 | ) | |
| 38 | end | |
| 39 | ||
| 40 | @doc """ | |
| 41 | Used across live components to populate select options with activity types. | |
| 42 | """ | |
| 43 | @spec populate_activity_types_list() :: [Klepsidra.TimeTracking.ActivityType.t(), ...] | |
| 44 | 0 | def populate_activity_types_list() do |
| 45 | [ | |
| 46 | {"", ""} | |
| 47 | | Klepsidra.TimeTracking.list_active_activity_types() | |
| 48 | 0 | |> Enum.map(fn type -> {type.name, type.id} end) |
| 49 | ] | |
| 50 | end | |
| 51 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.TimeTracking.Note do | |
| 1 | @moduledoc """ | |
| 2 | Defines the data schema for the `Note` entity, annotations of timed activities. | |
| 3 | """ | |
| 4 | ||
| 5 | use Ecto.Schema | |
| 6 | import Ecto.Changeset | |
| 7 | ||
| 8 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 9 | @foreign_key_type Ecto.UUID | |
| 10 | ||
| 11 | @type t :: %__MODULE__{ | |
| 12 | note: String.t(), | |
| 13 | timer_id: binary() | |
| 14 | } | |
| 15 | 36 | schema "timer_notes" do |
| 16 | field :note, :string | |
| 17 | belongs_to :timer, Klepsidra.TimeTracking.Timer, type: Ecto.UUID | |
| 18 | ||
| 19 | timestamps() | |
| 20 | end | |
| 21 | ||
| 22 | @doc false | |
| 23 | def changeset(note, attrs) do | |
| 24 | note | |
| 25 | |> cast(attrs, [:note, :timer_id]) | |
| 26 | |> validate_required([:note], message: "The message can't be empty") | |
| 27 | 3 | |> assoc_constraint(:timer) |
| 28 | end | |
| 29 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.TimeTracking.TimeUnits do | |
| 1 | @moduledoc """ | |
| 2 | Provides handling and user interface presentation of time units. | |
| 3 | """ | |
| 4 | ||
| 5 | alias Klepsidra.Cldr.Unit.Additional, as: AdditionalUnits | |
| 6 | ||
| 7 | @locale :en | |
| 8 | @style :narrow | |
| 9 | @default_billing_increment :thirty_minute_increment | |
| 10 | ||
| 11 | @doc """ | |
| 12 | Returns the default billing increment for use in option select controls' | |
| 13 | value property. | |
| 14 | ||
| 15 | The returned value is a string, to be immediately usable, without further | |
| 16 | conversion. The default is stored, compiled, in the module attribute | |
| 17 | `@default_billing_increment`, which will be supplanted in the future by | |
| 18 | a user-defined choice, directly in the user interface. | |
| 19 | """ | |
| 20 | @spec get_default_billing_increment() :: String.t() | |
| 21 | def get_default_billing_increment do | |
| 22 | 1 | Atom.to_string(@default_billing_increment) |
| 23 | end | |
| 24 | ||
| 25 | @doc """ | |
| 26 | Constructs a list of time units, ready to be used in an `options` input element. | |
| 27 | ||
| 28 | Returns list of tuples of the user-facing unit name and string version of the | |
| 29 | time unit atom, shaped for use in Phoenix-constructed [HTML] option input elements. | |
| 30 | Each tuple has two elements, the first human-readable value, the second | |
| 31 | is the string version of the time unit atom name. | |
| 32 | ||
| 33 | For example, weeks would be presented as: `{"Weeks", "week"}`. | |
| 34 | ||
| 35 | ## Examples | |
| 36 | ||
| 37 | iex> Klepsidra.TimeTracking.TimeUnits.construct_duration_unit_options_list() | |
| 38 | [ | |
| 39 | {"Minutes", "minute"}, | |
| 40 | {"5 min", "five_minute_increment"}, | |
| 41 | {"6 min", "six_minute_increment"}, | |
| 42 | {"10 min", "ten_minute_increment"}, | |
| 43 | {"12 min", "twelve_minute_increment"}, | |
| 44 | {"15 min", "fifteen_minute_increment"}, | |
| 45 | {"18 min", "eighteen_minute_increment"}, | |
| 46 | {"20 min", "twenty_minute_increment"}, | |
| 47 | {"24 min", "twenty_four_minute_increment"}, | |
| 48 | {"30 min", "thirty_minute_increment"}, | |
| 49 | {"36 min", "thirty_six_minute_increment"}, | |
| 50 | {"45 min", "fourty_five_minute_increment"}, | |
| 51 | {"60 min", "sixty_minute_increment"}, | |
| 52 | {"90 min", "ninety_minute_increment"}, | |
| 53 | {"2 hour increment", "one_hundred_twenty_minute_increment"} | |
| 54 | ] | |
| 55 | ||
| 56 | iex> Klepsidra.TimeTracking.TimeUnits.construct_duration_unit_options_list(use_primitives?: true) | |
| 57 | [{"Seconds", "second"}, {"Minutes", "minute"}, {"Hours", "hour"}] | |
| 58 | """ | |
| 59 | @spec construct_duration_unit_options_list() :: [{String.t(), String.t()}] | |
| 60 | def construct_duration_unit_options_list(opts \\ []) do | |
| 61 | 4 | use_time_primitives? = Keyword.get(opts, :use_primitives?, false) |
| 62 | ||
| 63 | 4 | case use_time_primitives? do |
| 64 | 2 | true -> |
| 65 | [{"Seconds", "second"}, {"Minutes", "minute"}, {"Hours", "hour"}] | |
| 66 | ||
| 67 | 2 | false -> |
| 68 | [ | |
| 69 | {"Minutes", "minute"} | |
| 70 | | AdditionalUnits.units_for(@locale, @style) | |
| 71 | 32 | |> Enum.map(fn {k, v} -> {v.display_name, Atom.to_string(k)} end) |
| 72 | ] | |
| 73 | end | |
| 74 | end | |
| 75 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.TimeTracking.Timer do | |
| 1 | @moduledoc """ | |
| 2 | Defines the `timers` schema and functions needed to clock in, out and | |
| 3 | parse datetimes. | |
| 4 | """ | |
| 5 | ||
| 6 | use Private | |
| 7 | use Ecto.Schema | |
| 8 | ||
| 9 | import Ecto.Changeset | |
| 10 | alias Klepsidra.BusinessPartners.BusinessPartner | |
| 11 | alias Klepsidra.Cldr.Unit | |
| 12 | alias Klepsidra.Projects.Project | |
| 13 | alias Klepsidra.TimeTracking.ActivityType | |
| 14 | ||
| 15 | @primary_key {:id, Ecto.UUID, autogenerate: true} | |
| 16 | @foreign_key_type Ecto.UUID | |
| 17 | ||
| 18 | @typedoc """ | |
| 19 | A duration tuple carries the integer magnitude of the time duration as the first item, | |
| 20 | and a string encoding of the time increment atom recognised by the system. | |
| 21 | """ | |
| 22 | @type duration_tuple :: {integer(), bitstring()} | |
| 23 | ||
| 24 | @type t :: %__MODULE__{ | |
| 25 | id: Ecto.UUID.t(), | |
| 26 | start_stamp: String.t(), | |
| 27 | end_stamp: String.t(), | |
| 28 | duration: integer, | |
| 29 | duration_time_unit: String.t(), | |
| 30 | description: String.t(), | |
| 31 | project_id: integer, | |
| 32 | billable: boolean, | |
| 33 | business_partner_id: integer, | |
| 34 | activity_type_id: String.t(), | |
| 35 | billing_rate: number(), | |
| 36 | billing_duration: integer, | |
| 37 | billing_duration_time_unit: String.t(), | |
| 38 | inserted_at: String.t(), | |
| 39 | updated_at: String.t() | |
| 40 | } | |
| 41 | 286 | schema "timers" do |
| 42 | field(:start_stamp, :string) | |
| 43 | field(:end_stamp, :string) | |
| 44 | field(:duration, :integer, default: nil) | |
| 45 | field(:duration_time_unit, :string) | |
| 46 | field(:description, :string) | |
| 47 | ||
| 48 | belongs_to(:project, Project, type: Ecto.UUID) | |
| 49 | ||
| 50 | field(:billable, :boolean, default: false) | |
| 51 | ||
| 52 | belongs_to(:business_partner, BusinessPartner, type: Ecto.UUID) | |
| 53 | belongs_to(:activity_type, ActivityType, type: Ecto.UUID) | |
| 54 | ||
| 55 | field(:billing_rate, :decimal) | |
| 56 | field(:billing_duration, :integer) | |
| 57 | field(:billing_duration_time_unit, :string) | |
| 58 | ||
| 59 | many_to_many(:tags, Klepsidra.Categorisation.Tag, | |
| 60 | join_through: "timer_tags", | |
| 61 | on_replace: :delete, | |
| 62 | preload_order: [asc: :name] | |
| 63 | ) | |
| 64 | ||
| 65 | has_many(:notes, Klepsidra.TimeTracking.Note, on_delete: :delete_all) | |
| 66 | ||
| 67 | timestamps() | |
| 68 | end | |
| 69 | ||
| 70 | @doc false | |
| 71 | def changeset(timer, attrs) do | |
| 72 | timer | |
| 73 | |> cast(attrs, [ | |
| 74 | :start_stamp, | |
| 75 | :end_stamp, | |
| 76 | :duration, | |
| 77 | :duration_time_unit, | |
| 78 | :description, | |
| 79 | :project_id, | |
| 80 | :billable, | |
| 81 | :business_partner_id, | |
| 82 | :activity_type_id, | |
| 83 | :billing_rate, | |
| 84 | :billing_duration, | |
| 85 | :billing_duration_time_unit | |
| 86 | ]) | |
| 87 | |> validate_required(:start_stamp, message: "Enter a start date and time") | |
| 88 | |> validate_timestamps_and_chronology(:start_stamp, :end_stamp) | |
| 89 | 19 | |> unique_constraint(:project) |
| 90 | end | |
| 91 | ||
| 92 | @default_date_format Application.compile_env(:klepsidra, [__MODULE__, :default_date_format]) | |
| 93 | @default_time_format Application.compile_env(:klepsidra, [__MODULE__, :default_time_format]) | |
| 94 | ||
| 95 | @doc """ | |
| 96 | Validate that the `end_timestamp` is chronologically after the `start_timestamp`. | |
| 97 | ||
| 98 | ## Options | |
| 99 | ||
| 100 | * `:message` - the message on failure, defaults to "Timestamps are not in valid order" | |
| 101 | ||
| 102 | """ | |
| 103 | @spec validate_timestamps_and_chronology( | |
| 104 | changeset :: Ecto.Changeset.t(), | |
| 105 | start_timestamp :: atom, | |
| 106 | end_timestamp :: atom, | |
| 107 | opts :: Keyword.t() | |
| 108 | ) :: Ecto.Changeset.t() | |
| 109 | def validate_timestamps_and_chronology(changeset, start_timestamp, end_timestamp, opts \\ []) do | |
| 110 | 19 | _message = Keyword.get(opts, :message, "Timestamps are not in valid order") |
| 111 | 19 | start_stamp = get_field(changeset, start_timestamp, "") |
| 112 | ||
| 113 | 19 | parsed_start_stamp = |
| 114 | case parse_html_datetime(start_stamp) do | |
| 115 | 17 | {:ok, start_datetime_stamp} -> start_datetime_stamp |
| 116 | 2 | _ -> nil |
| 117 | end | |
| 118 | ||
| 119 | 19 | end_stamp = get_field(changeset, end_timestamp, "") || "" |
| 120 | ||
| 121 | 19 | parsed_end_stamp = |
| 122 | case parse_html_datetime(end_stamp) do | |
| 123 | 16 | {:ok, end_datetime_stamp} -> end_datetime_stamp |
| 124 | 3 | _ -> nil |
| 125 | end | |
| 126 | ||
| 127 | 19 | with {:is_valid, true} <- {:is_valid, changeset.valid?}, |
| 128 | 16 | {:nonempty_start_stamp, true} <- |
| 129 | {:nonempty_start_stamp, start_stamp != ""}, | |
| 130 | 16 | {:valid_start_stamp, true} <- |
| 131 | 16 | {:valid_start_stamp, is_struct(parsed_start_stamp, NaiveDateTime)}, |
| 132 | 16 | {:nonempty_end_stamp, true} <- {:nonempty_end_stamp, end_stamp != ""}, |
| 133 | 16 | {:valid_end_stamp, true} <- |
| 134 | 16 | {:valid_end_stamp, is_struct(parsed_end_stamp, NaiveDateTime)}, |
| 135 | 16 | {:chronological_order, true} <- |
| 136 | {:chronological_order, NaiveDateTime.before?(parsed_start_stamp, parsed_end_stamp)}, | |
| 137 | 16 | {:reasonable_duration_check, true} <- |
| 138 | {:reasonable_duration_check, | |
| 139 | NaiveDateTime.before?( | |
| 140 | parsed_end_stamp, | |
| 141 | NaiveDateTime.add(parsed_start_stamp, 24, :hour) | |
| 142 | )} do | |
| 143 | 16 | changeset |
| 144 | else | |
| 145 | {:is_valid, false} -> | |
| 146 | 3 | changeset |
| 147 | ||
| 148 | {:nonempty_start_stamp, false} -> | |
| 149 | 0 | add_error(changeset, :end_stamp, "You must provide a start time and date") |
| 150 | ||
| 151 | {:valid_start_stamp, false} -> | |
| 152 | 0 | add_error(changeset, :end_stamp, "The start time and date is not valid") |
| 153 | ||
| 154 | {:nonempty_end_stamp, false} -> | |
| 155 | 0 | changeset |
| 156 | ||
| 157 | {:valid_end_stamp, false} -> | |
| 158 | 0 | add_error(changeset, :end_stamp, "The end time and date is not valid") |
| 159 | ||
| 160 | {:chronological_order, false} -> | |
| 161 | 0 | add_error(changeset, :end_stamp, "The end time must follow the start time") |
| 162 | ||
| 163 | {:reasonable_duration_check, false} -> | |
| 164 | 0 | add_error(changeset, :end_stamp, "The timed activity cannot be longer than one day") |
| 165 | end | |
| 166 | end | |
| 167 | ||
| 168 | @doc """ | |
| 169 | Get the current local date and time, without a timezone component, | |
| 170 | for the timezone the the computer the program is running on is set to. | |
| 171 | ||
| 172 | This function will display what the date and time are right now, for the | |
| 173 | time zone configuration the computer is localised to. | |
| 174 | ||
| 175 | Relying on this function to return the time is fine for many uses, including for | |
| 176 | timing tasks, but is unsuitable for use where real precision and time awareness may be | |
| 177 | critical. | |
| 178 | ||
| 179 | Returns a `NaiveDateTime` struct. | |
| 180 | ||
| 181 | ## Examples | |
| 182 | ||
| 183 | iex> naivedatetime_stamp = Klepsidra.TimeTracking.Timer.get_current_timestamp() | |
| 184 | iex> naivedatetime_stamp.year >= 2024 | |
| 185 | true | |
| 186 | """ | |
| 187 | @spec get_current_timestamp() :: NaiveDateTime.t() | |
| 188 | def get_current_timestamp do | |
| 189 | 7 | NaiveDateTime.local_now() |
| 190 | end | |
| 191 | ||
| 192 | @doc """ | |
| 193 | Calculates the time elapsed between start and end timestamps. | |
| 194 | ||
| 195 | The time unit can be passed in as the optional `unit` argument. If it is omitted, | |
| 196 | minutes are used as the default time unit. | |
| 197 | ||
| 198 | In calculating the time duration, the difference between the two timestamps is | |
| 199 | always incremented by one. This ensures that if the timer were simply started | |
| 200 | and immediately stopped, it would still register the use of one unit of time. | |
| 201 | ||
| 202 | If the start and end `datetime` stamps are empty strings, or nil values, returns | |
| 203 | zero duration to reduce the number of error conditions. | |
| 204 | ||
| 205 | ## Examples | |
| 206 | ||
| 207 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45") | |
| 208 | 72 | |
| 209 | ||
| 210 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45", :minute) | |
| 211 | 72 | |
| 212 | ||
| 213 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45", :second) | |
| 214 | 4261 | |
| 215 | ||
| 216 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45", :hour) | |
| 217 | 2 | |
| 218 | ||
| 219 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:34", :hour) | |
| 220 | 2 | |
| 221 | ||
| 222 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:33", :hour) | |
| 223 | 1 | |
| 224 | ||
| 225 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45]) | |
| 226 | 104 | |
| 227 | ||
| 228 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45], :minute) | |
| 229 | 104 | |
| 230 | ||
| 231 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45], :second) | |
| 232 | 6195 | |
| 233 | ||
| 234 | iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45], :hour) | |
| 235 | 2 | |
| 236 | """ | |
| 237 | @spec calculate_timer_duration(String.t(), String.t(), atom()) :: integer() | |
| 238 | @spec calculate_timer_duration(NaiveDateTime.t(), NaiveDateTime.t(), atom()) :: integer() | |
| 239 | 2 | def calculate_timer_duration(start_timestamp, end_timestamp, unit \\ :minute) |
| 240 | ||
| 241 | 0 | def calculate_timer_duration("", "", _unit), do: 0 |
| 242 | 0 | def calculate_timer_duration(nil, nil, _unit), do: 0 |
| 243 | ||
| 244 | def calculate_timer_duration(start_timestamp, end_timestamp, unit) | |
| 245 | when is_bitstring(start_timestamp) and is_bitstring(end_timestamp) and is_atom(unit) do | |
| 246 | 6 | calculate_timer_duration( |
| 247 | parse_html_datetime!(start_timestamp), | |
| 248 | parse_html_datetime!(end_timestamp), | |
| 249 | unit | |
| 250 | ) | |
| 251 | end | |
| 252 | ||
| 253 | def calculate_timer_duration(start_timestamp, end_timestamp, unit) | |
| 254 | when is_struct(start_timestamp, NaiveDateTime) and | |
| 255 | is_struct( | |
| 256 | end_timestamp, | |
| 257 | NaiveDateTime | |
| 258 | ) and is_atom(unit) do | |
| 259 | 12 | with {:end_follows_start, true} <- |
| 260 | {:end_follows_start, NaiveDateTime.after?(end_timestamp, start_timestamp)}, | |
| 261 | 12 | {:uses_time_unit_primitive, true} <- |
| 262 | 12 | {:uses_time_unit_primitive, unit in [:second, :minute, :hour, :day]} do |
| 263 | 12 | NaiveDateTime.diff(end_timestamp, start_timestamp, unit) + 1 |
| 264 | else | |
| 265 | 0 | {:end_follows_start, false} -> |
| 266 | 0 | |
| 267 | ||
| 268 | {:uses_time_unit_primitive, false} -> | |
| 269 | (NaiveDateTime.diff(end_timestamp, start_timestamp, :minute) + 1) | |
| 270 | |> Cldr.Unit.new!(:minute) | |
| 271 | |> Klepsidra.Cldr.Unit.convert!(unit) | |
| 272 | |> Map.get(:value) | |
| 273 | |> Decimal.round(0, :up) | |
| 274 | 0 | |> Decimal.to_integer() |
| 275 | end | |
| 276 | end | |
| 277 | ||
| 278 | @doc """ | |
| 279 | Clock out of an active timer, given a starting timestamp string. | |
| 280 | ||
| 281 | ## Return values | |
| 282 | ||
| 283 | Returns a map containing the ending timestamp and duration in the requested unit of time. | |
| 284 | ||
| 285 | ## Examples | |
| 286 | ||
| 287 | iex> Klepsidra.TimeTracking.Timer.get_current_timestamp() | |
| 288 | ...> |> NaiveDateTime.add(-15, :minute) | |
| 289 | ...> |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!() | |
| 290 | ...> |> Klepsidra.TimeTracking.Timer.clock_out() | |
| 291 | %{end_timestamp: Klepsidra.TimeTracking.Timer.get_current_timestamp() |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!(), timer_duration: 16} | |
| 292 | ||
| 293 | iex> Klepsidra.TimeTracking.Timer.get_current_timestamp() | |
| 294 | ...> |> NaiveDateTime.add(-15, :minute) | |
| 295 | ...> |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!() | |
| 296 | ...> |> Klepsidra.TimeTracking.Timer.clock_out(:hour) | |
| 297 | %{end_timestamp: Klepsidra.TimeTracking.Timer.get_current_timestamp() |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!(), timer_duration: 1} | |
| 298 | """ | |
| 299 | @spec clock_out(String.t(), atom()) :: %{end_timestamp: String.t(), timer_duration: integer()} | |
| 300 | 1 | def clock_out(start_timestamp, unit \\ :minute) |
| 301 | when is_bitstring(start_timestamp) and is_atom(unit) do | |
| 302 | 2 | end_timestamp = get_current_timestamp() |
| 303 | ||
| 304 | 2 | %{ |
| 305 | end_timestamp: convert_naivedatetime_to_html!(end_timestamp), | |
| 306 | timer_duration: | |
| 307 | calculate_timer_duration( | |
| 308 | parse_html_datetime!(start_timestamp), | |
| 309 | end_timestamp, | |
| 310 | unit | |
| 311 | ) | |
| 312 | } | |
| 313 | end | |
| 314 | ||
| 315 | @doc """ | |
| 316 | Parses HTML `datetime-local` strings into `NativeDateTime` structure. | |
| 317 | ||
| 318 | Datetime strings coming from HTML, from `datetime-local` type fields, | |
| 319 | are not conformant to the extended date and time of day ISO 8601:2019 standard format. | |
| 320 | Specifically, they are encoded as "YYYY-MM-DDThh:mm", and are generally (but not always) | |
| 321 | passed without a seconds component. `NativeDateTime` cannot parse this, returning an | |
| 322 | error. | |
| 323 | ||
| 324 | Using the Timex library's `parse/2` function, parse datetime strings into an ISO | |
| 325 | conforming `NativeDateTime` structure, returning a result tuple: | |
| 326 | ||
| 327 | `{:ok, ~N[...]}` on success, or {:error, reason} upon failure. | |
| 328 | ||
| 329 | It is possible to receive a datetime with date and time components separated by either | |
| 330 | a letter "t" or a single space (" "), binary pattern matching will determine which is | |
| 331 | received in the `datetime_string` argument. | |
| 332 | ||
| 333 | An error is returned if the datetime string cannot be parsed as a valid date and time, | |
| 334 | and also if the string doesn't match the expected pattern. | |
| 335 | ||
| 336 | ## Examples | |
| 337 | ||
| 338 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01T11:15") | |
| 339 | {:ok, ~N[1970-01-01 11:15:00]} | |
| 340 | ||
| 341 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01T11:15:39") | |
| 342 | {:ok, ~N[1970-01-01 11:15:39]} | |
| 343 | ||
| 344 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01 11:15") | |
| 345 | {:ok, ~N[1970-01-01 11:15:00]} | |
| 346 | ||
| 347 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01 11:15:59") | |
| 348 | {:ok, ~N[1970-01-01 11:15:59]} | |
| 349 | ||
| 350 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-02-29T11:15") | |
| 351 | {:error, :invalid_date} | |
| 352 | ||
| 353 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("") | |
| 354 | {:error, "Invalid argument passed as timestamp"} | |
| 355 | ||
| 356 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime(nil) | |
| 357 | {:error, "Invalid argument passed as timestamp"} | |
| 358 | """ | |
| 359 | @spec parse_html_datetime(String.t()) :: {:ok, NaiveDateTime.t()} | {:error, String.t()} | |
| 360 | def parse_html_datetime( | |
| 361 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T", | |
| 362 | _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string | |
| 363 | ) | |
| 364 | when is_bitstring(datetime_string) do | |
| 365 | 4 | Timex.parse(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}") |
| 366 | end | |
| 367 | ||
| 368 | def parse_html_datetime( | |
| 369 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ", | |
| 370 | _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string | |
| 371 | ) | |
| 372 | when is_bitstring(datetime_string) do | |
| 373 | 17 | Timex.parse(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}") |
| 374 | end | |
| 375 | ||
| 376 | def parse_html_datetime( | |
| 377 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T", | |
| 378 | _hour::binary-size(2), ":", _minute::binary-size(2), ":", | |
| 379 | _second::binary-size(2)>> = datetime_string | |
| 380 | ) | |
| 381 | when is_bitstring(datetime_string) do | |
| 382 | 1 | Timex.parse(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}") |
| 383 | end | |
| 384 | ||
| 385 | def parse_html_datetime( | |
| 386 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ", | |
| 387 | _hour::binary-size(2), ":", _minute::binary-size(2), ":", | |
| 388 | _second::binary-size(2)>> = datetime_string | |
| 389 | ) | |
| 390 | when is_bitstring(datetime_string) do | |
| 391 | 16 | Timex.parse(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}:{0s}") |
| 392 | end | |
| 393 | ||
| 394 | 7 | def parse_html_datetime(_), do: {:error, "Invalid argument passed as timestamp"} |
| 395 | ||
| 396 | @doc """ | |
| 397 | Parses HTML `datetime-local` strings into `NativeDateTime` structure. | |
| 398 | ||
| 399 | Works just like `parse_html_datetime\1`, but instead of returning an {:ok, _} or | |
| 400 | {:error, reason} tuple, returns the `NaiveDateTime` struct on success, raising | |
| 401 | an error otherwise. | |
| 402 | ||
| 403 | ## Examples | |
| 404 | ||
| 405 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01T11:15") | |
| 406 | ~N[1970-01-01 11:15:00] | |
| 407 | ||
| 408 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01T11:15:39") | |
| 409 | ~N[1970-01-01 11:15:39] | |
| 410 | ||
| 411 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01 11:15") | |
| 412 | ~N[1970-01-01 11:15:00] | |
| 413 | ||
| 414 | iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01 11:15:59") | |
| 415 | ~N[1970-01-01 11:15:59] | |
| 416 | ||
| 417 | """ | |
| 418 | @spec parse_html_datetime!(String.t()) :: NaiveDateTime.t() | |
| 419 | def parse_html_datetime!( | |
| 420 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T", | |
| 421 | _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string | |
| 422 | ) | |
| 423 | when is_bitstring(datetime_string) do | |
| 424 | 1 | Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}") |
| 425 | end | |
| 426 | ||
| 427 | def parse_html_datetime!( | |
| 428 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ", | |
| 429 | _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string | |
| 430 | ) | |
| 431 | when is_bitstring(datetime_string) do | |
| 432 | 21 | Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}") |
| 433 | end | |
| 434 | ||
| 435 | def parse_html_datetime!( | |
| 436 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T", | |
| 437 | _hour::binary-size(2), ":", _minute::binary-size(2), ":", | |
| 438 | _second::binary-size(2)>> = datetime_string | |
| 439 | ) | |
| 440 | when is_bitstring(datetime_string) do | |
| 441 | 3 | Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}") |
| 442 | end | |
| 443 | ||
| 444 | def parse_html_datetime!( | |
| 445 | <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ", | |
| 446 | _hour::binary-size(2), ":", _minute::binary-size(2), ":", | |
| 447 | _second::binary-size(2)>> = datetime_string | |
| 448 | ) | |
| 449 | when is_bitstring(datetime_string) do | |
| 450 | 9 | Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}:{0s}") |
| 451 | end | |
| 452 | ||
| 453 | @doc """ | |
| 454 | Converts `NativeDateTime` structure to HTML-ready string, with the seconds component | |
| 455 | elided. | |
| 456 | ||
| 457 | Returns a tuple with `:ok` or `:error` as the first element, with a string | |
| 458 | compatible with HTML's input `datetime-local` element, in the format | |
| 459 | "YYYY-MM-DDThh:mm". This can directly be fed into an `input` element's `value` | |
| 460 | slot. | |
| 461 | ||
| 462 | ## Examples | |
| 463 | ||
| 464 | iex> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html(~N[2024-04-07 22:12:32]) | |
| 465 | {:ok, "2024-04-07T22:12:32"} | |
| 466 | """ | |
| 467 | @spec convert_naivedatetime_to_html(NaiveDateTime.t()) :: | |
| 468 | {:ok, String.t()} | {:error, String.t()} | |
| 469 | def convert_naivedatetime_to_html(datetime_stamp) | |
| 470 | when is_struct(datetime_stamp, NaiveDateTime) do | |
| 471 | 1 | Timex.format(datetime_stamp, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}") |
| 472 | end | |
| 473 | ||
| 474 | @doc """ | |
| 475 | Converts `NativeDateTime` structure to HTML-ready string, with the seconds component | |
| 476 | elided. | |
| 477 | ||
| 478 | Returns a string compatible with HTML's input `datetime-local` element, in the | |
| 479 | format "YYYY-MM-DDThh:mm". This can directly be fed into an `input` element's | |
| 480 | `value` slot. | |
| 481 | ||
| 482 | ## Examples | |
| 483 | ||
| 484 | iex> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!(~N[2024-04-07 22:12:32]) | |
| 485 | "2024-04-07T22:12:32" | |
| 486 | """ | |
| 487 | @spec convert_naivedatetime_to_html!(NaiveDateTime.t()) :: String.t() | |
| 488 | def convert_naivedatetime_to_html!(datetime_stamp) | |
| 489 | when is_struct(datetime_stamp, NaiveDateTime) do | |
| 490 | 7 | Timex.format!(datetime_stamp, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}") |
| 491 | end | |
| 492 | ||
| 493 | @doc """ | |
| 494 | Formats a number into a string according to a unit definition for a locale. | |
| 495 | ||
| 496 | Takes an integer duration, and an atom time unit, including any custom time | |
| 497 | units defined and compiled as part of this project. | |
| 498 | ||
| 499 | Returns a tuple {:ok, ...} containing a locale-specific and quantity-sensitive | |
| 500 | pluralisation of the defined time unit as a string. | |
| 501 | ||
| 502 | ## Examples | |
| 503 | ||
| 504 | iex> Klepsidra.TimeTracking.Timer.duration_to_string(3, :minute) | |
| 505 | {:ok, "3 minutes"} | |
| 506 | ||
| 507 | iex> Klepsidra.TimeTracking.Timer.duration_to_string(7, :six_minute_increment) | |
| 508 | {:ok, "7 six minute increments"} | |
| 509 | ||
| 510 | iex> Klepsidra.TimeTracking.Timer.duration_to_string(1, :hour) | |
| 511 | {:ok, "1 hour"} | |
| 512 | ||
| 513 | iex> Klepsidra.TimeTracking.Timer.duration_to_string(0, :second) | |
| 514 | {:ok, "0 seconds"} | |
| 515 | """ | |
| 516 | @spec duration_to_string(duration :: integer(), time_unit :: atom()) :: | |
| 517 | {:ok, String.t()} | {:error, {atom(), String.t()}} | |
| 518 | def duration_to_string(duration, time_unit) when is_integer(duration) and is_atom(time_unit) do | |
| 519 | 4 | Cldr.Unit.to_string(Cldr.Unit.new!(time_unit, duration)) |
| 520 | end | |
| 521 | ||
| 522 | @doc """ | |
| 523 | Used across `timer` live components to calculate timer durations. | |
| 524 | ||
| 525 | The function takes the `timer_params` parameters passed to the validation function, | |
| 526 | extracts the start and end datetime stamps, returning a map with the two | |
| 527 | calculated durations: `%{duration: 0, billing_duration: 0}` | |
| 528 | """ | |
| 529 | @spec assign_timer_duration(%{optional(any) => any}, String.t()) :: integer() | |
| 530 | def assign_timer_duration(timer_params, duration_time_unit) | |
| 531 | when is_map(timer_params) and is_bitstring(duration_time_unit) do | |
| 532 | 0 | start_stamp = timer_params["start_stamp"] |
| 533 | 0 | end_stamp = timer_params["end_stamp"] |
| 534 | 0 | duration_time_unit = timer_params[duration_time_unit] |
| 535 | ||
| 536 | 0 | with true <- start_stamp != "", |
| 537 | 0 | true <- end_stamp != "", |
| 538 | 0 | true <- duration_time_unit != "" do |
| 539 | 0 | calculate_timer_duration( |
| 540 | start_stamp, | |
| 541 | end_stamp, | |
| 542 | String.to_atom(duration_time_unit) | |
| 543 | ) | |
| 544 | else | |
| 545 | _ -> 0 | |
| 546 | end | |
| 547 | end | |
| 548 | ||
| 549 | def read_checkbox(field) do | |
| 550 | 0 | Phoenix.HTML.Form.normalize_value("checkbox", field) |
| 551 | end | |
| 552 | ||
| 553 | @doc """ | |
| 554 | Takes in a single duration tuple, shaped as `{duration, string_duration_time_unit}`, | |
| 555 | converting it to a duration in seconds, the base time unit. | |
| 556 | """ | |
| 557 | @spec convert_duration_to_base_time_unit(duration_tuple :: duration_tuple()) :: | |
| 558 | Cldr.Unit.t() | |
| 559 | def convert_duration_to_base_time_unit(duration_tuple) | |
| 560 | when is_tuple(duration_tuple) and tuple_size(duration_tuple) == 2 do | |
| 561 | 8 | {duration, duration_time_unit} = duration_tuple |
| 562 | ||
| 563 | Unit.new!(duration, convert_string_to_time_unit_atom(duration_time_unit)) | |
| 564 | 8 | |> Unit.convert!(:second) |
| 565 | end | |
| 566 | ||
| 567 | @doc """ | |
| 568 | Takes in a list of duration tuples, shaped as `{duration, string_duration_time_unit}`, | |
| 569 | converting them all to durations in seconds, the base time unit. | |
| 570 | """ | |
| 571 | @spec convert_durations_to_base_time_unit(durations_list :: [duration_tuple(), ...]) :: [ | |
| 572 | Cldr.Unit.t(), | |
| 573 | ... | |
| 574 | ] | |
| 575 | 6 | def convert_durations_to_base_time_unit([]), do: [] |
| 576 | ||
| 577 | def convert_durations_to_base_time_unit(durations_list) | |
| 578 | when is_list(durations_list) do | |
| 579 | durations_list | |
| 580 | 0 | |> Enum.map(fn duration_tuple -> convert_duration_to_base_time_unit(duration_tuple) end) |
| 581 | end | |
| 582 | ||
| 583 | @spec convert_string_to_time_unit_atom(String.t()) :: atom() | |
| 584 | defp convert_string_to_time_unit_atom(time_unit) when is_bitstring(time_unit) do | |
| 585 | 8 | cond do |
| 586 | 8 | time_unit == "minute" -> :minute_increment |
| 587 | 0 | time_unit == "hour" -> :hour_increment |
| 588 | 0 | true -> String.to_existing_atom(time_unit) |
| 589 | end | |
| 590 | end | |
| 591 | ||
| 592 | @doc """ | |
| 593 | Takes a list of `Cldr.Unit` structures, timed in the base unit for time, | |
| 594 | seconds, summing them all to return a total duration in the same time unit. | |
| 595 | """ | |
| 596 | @spec sum_base_unit_durations(durations_list :: [Cldr.Unit.t(), ...]) :: Cldr.Unit.t() | |
| 597 | def sum_base_unit_durations(durations_list) | |
| 598 | when is_list(durations_list) do | |
| 599 | durations_list | |
| 600 | 6 | |> Enum.reduce(Unit.new!(:second, 0), fn i, acc -> |
| 601 | 0 | Unit.add(i, acc) |
| 602 | end) | |
| 603 | end | |
| 604 | ||
| 605 | @doc """ | |
| 606 | Takes two `Cldr.Unit` structures, the aggregate time and the latest deleted timer, | |
| 607 | timed in, seconds, the base unit of time, returning the result of their difference. | |
| 608 | """ | |
| 609 | @spec subtract_base_unit_durations(duration_1 :: map(), duration_2 :: map()) :: Cldr.Unit.t() | |
| 610 | def subtract_base_unit_durations(duration_1, duration_2) | |
| 611 | when is_struct(duration_1, Cldr.Unit) and is_struct(duration_2, Cldr.Unit) do | |
| 612 | 0 | Unit.sub!(duration_1, duration_2) |
| 613 | end | |
| 614 | ||
| 615 | @doc """ | |
| 616 | Takes in a `Cldr.Unit` structure, denoting a duration, decomposing it into | |
| 617 | human-intuitive time increments, rounding it to the nearest of each unit, | |
| 618 | formatting it all as an easy to read string. | |
| 619 | ||
| 620 | By default, the decomposition will be into hours and minutes, but a list | |
| 621 | consisting of any time increment can be used here, e.g.: | |
| 622 | `[:day, :hour_increment, :minute_increment]`. | |
| 623 | ||
| 624 | A zero time value will return a nil result. | |
| 625 | ||
| 626 | ## Examples | |
| 627 | ||
| 628 | #iex> 3600 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration() | |
| 629 | #"1 hour" | |
| 630 | #iex> 0 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration() | |
| 631 | #nil | |
| 632 | #iex> 5000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration() | |
| 633 | #"1 hour and 23 minutes" | |
| 634 | #iex> 95000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration(unit_list: [:hour_increment, :minute_increment]) | |
| 635 | #"26 hours and 23 minutes" | |
| 636 | #iex> 95000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration(unit_list: [:day, :hour_increment, :minute_increment]) | |
| 637 | #"1 day, 2 hours and 23 minutes" | |
| 638 | #iex> 95000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration(unit_list: [:day, :hour_increment]) | |
| 639 | #"1 day and 2 hours" | |
| 640 | """ | |
| 641 | @spec format_human_readable_duration(duration :: Cldr.Unit.t(), options :: keyword()) :: | |
| 642 | nil | bitstring() | |
| 643 | 8 | def format_human_readable_duration(duration, options \\ []) |
| 644 | when is_struct(duration, Cldr.Unit) do | |
| 645 | 14 | unit_list = |
| 646 | Keyword.get(options, :unit_list, [:hour_increment, :minute_increment]) | |
| 647 | ||
| 648 | 14 | restrict_if_components_only = |
| 649 | Keyword.get(options, :restrict_if_components_only, false) | |
| 650 | ||
| 651 | 14 | case decompose_unit(duration, unit_list, |
| 652 | restrict_if_components_only: restrict_if_components_only | |
| 653 | ) do | |
| 654 | 0 | nil -> |
| 655 | nil | |
| 656 | ||
| 657 | unit_composition -> | |
| 658 | unit_composition | |
| 659 | 8 | |> Enum.map(fn i -> Unit.round(i, 0) end) |
| 660 | 14 | |> then(fn |
| 661 | 6 | [] -> nil |
| 662 | 8 | list -> Unit.to_string!(list) |
| 663 | end) | |
| 664 | end | |
| 665 | end | |
| 666 | ||
| 667 | @doc """ | |
| 668 | Decompose a unit into component subunits. | |
| 669 | ||
| 670 | Any list compatible units can be provided however a list of units of | |
| 671 | decreasing scale is recommended. | |
| 672 | ||
| 673 | ## Arguments | |
| 674 | ||
| 675 | * `unit` is any unit returned by `Cldr.Unit.new/2` | |
| 676 | * `subunit_list` is a list of valid units. All units must be from the same | |
| 677 | category | |
| 678 | ||
| 679 | ## Returns | |
| 680 | ||
| 681 | A map containing: | |
| 682 | ||
| 683 | * `number_of_subunits` the number of component subunits | |
| 684 | * `unit_composition` a list of units after decomposition | |
| 685 | ||
| 686 | ## Examples | |
| 687 | ||
| 688 | iex> 21.17 |> Cldr.Unit.new!(:hour_increment) |> Klepsidra.TimeTracking.Timer.decompose_unit([:day, :hour_increment]) | |
| 689 | [Cldr.Unit.new!(:hour_increment, "21.1699999999999992")] | |
| 690 | ||
| 691 | iex> 121.17 |> Cldr.Unit.new!(:hour_increment) |> Klepsidra.TimeTracking.Timer.decompose_unit([:day, :hour_increment]) | |
| 692 | [Cldr.Unit.new!(:day, 5), Cldr.Unit.new!(:hour_increment, "1.17")] | |
| 693 | ||
| 694 | iex> 21.17 |> Klepsidra.TimeTracking.Timer.decompose_unit([:day, :hour_increment]) | |
| 695 | {:error, "Invalid unit or subunit_list"} | |
| 696 | ||
| 697 | iex> 21.17 |> Cldr.Unit.new!(:hour_increment) |> Klepsidra.TimeTracking.Timer.decompose_unit("") | |
| 698 | {:error, "Invalid unit or subunit_list"} | |
| 699 | """ | |
| 700 | @spec decompose_unit(unit :: Cldr.Unit.t(), subunit_list :: list(), options :: keyword()) :: | |
| 701 | nil | list(any()) | |
| 702 | @spec decompose_unit(unit :: any(), subunit_list :: any(), options :: keyword()) :: | |
| 703 | {:error, String.t()} | |
| 704 | 4 | def decompose_unit(unit, subunit_list, options \\ []) |
| 705 | ||
| 706 | def decompose_unit(unit, subunit_list, options) | |
| 707 | when is_struct(unit, Cldr.Unit) and is_list(subunit_list) do | |
| 708 | 16 | restrict_if_components_only = Keyword.get(options, :restrict_if_components_only, false) |
| 709 | ||
| 710 | Unit.decompose(unit, subunit_list) | |
| 711 | 16 | |> adjust_for_restricted_subunits(restrict_if_components_only) |
| 712 | end | |
| 713 | ||
| 714 | 2 | def decompose_unit(_unit, _subunit_list, _options), |
| 715 | do: {:error, "Invalid unit or subunit_list"} | |
| 716 | ||
| 717 | private do | |
| 718 | @spec adjust_for_restricted_subunits( | |
| 719 | unit_composition :: [Cldr.Unit.t(), ...], | |
| 720 | restricted_subunits :: list() | |
| 721 | ) :: | |
| 722 | nil | list() | |
| 723 | def adjust_for_restricted_subunits(unit_composition, [_ | _] = restricted_subunits) | |
| 724 | when is_list(unit_composition) do | |
| 725 | 5 | restricted_list = MapSet.new(restricted_subunits) |
| 726 | ||
| 727 | unit_composition | |
| 728 | 9 | |> Enum.reject(fn %{unit: unit} -> MapSet.member?(restricted_list, unit) end) |
| 729 | 5 | |> non_empty_list?(unit_composition) |
| 730 | end | |
| 731 | ||
| 732 | 16 | def adjust_for_restricted_subunits(unit_composition, _), do: unit_composition |
| 733 | end | |
| 734 | ||
| 735 | private do | |
| 736 | @spec non_empty_list?(list :: nonempty_list(), return :: list()) :: as_boolean(term) | |
| 737 | 5 | def non_empty_list?([], _), do: nil |
| 738 | 2 | def non_empty_list?(list, []) when is_list(list), do: list |
| 739 | 1 | def non_empty_list?(_list, return) when is_list(return), do: return |
| 740 | end | |
| 741 | ||
| 742 | @doc """ | |
| 743 | Format a `NaiveDateTime` into human readable date. | |
| 744 | ||
| 745 | ## Examples | |
| 746 | ||
| 747 | iex> Klepsidra.TimeTracking.Timer.format_human_readable_date(~N[2024-01-23 12:34:56]) | |
| 748 | {:ok, "Tuesday, 23 Jan 2024"} | |
| 749 | """ | |
| 750 | @spec format_human_readable_date(NaiveDateTime.t()) :: | |
| 751 | {:ok, String.t()} | {:error, {atom(), String.t()}} | |
| 752 | 2 | def format_human_readable_date(datetime, format_string \\ @default_date_format) |
| 753 | when is_struct(datetime, NaiveDateTime) and is_bitstring(format_string) do | |
| 754 | 2 | Timex.format(datetime, format_string) |
| 755 | end | |
| 756 | ||
| 757 | @doc """ | |
| 758 | Format a `NaiveDateTime` into a human-readable time, displaying hours and minutes by default, in 24-hour time, returning a tuple with the return status and the formatted time string. | |
| 759 | ||
| 760 | ## Arguments | |
| 761 | ||
| 762 | * `datetime`, a valid `NaiveDateTime` structure | |
| 763 | * `format_string`, an optional format string, in `Timex` default formatting language. | |
| 764 | ||
| 765 | ## Returns | |
| 766 | ||
| 767 | A tuple with return status and a string, either | |
| 768 | ||
| 769 | * {:ok, formatted time string} | |
| 770 | ||
| 771 | or | |
| 772 | ||
| 773 | * {:error, error message} | |
| 774 | ||
| 775 | ## Examples | |
| 776 | ||
| 777 | iex> Klepsidra.TimeTracking.Timer.format_human_readable_time(~N[2024-01-23 12:34:56]) | |
| 778 | {:ok, "12:34"} | |
| 779 | """ | |
| 780 | @spec format_human_readable_time(NaiveDateTime.t()) :: {:ok | :error, bitstring()} | |
| 781 | 7 | def format_human_readable_time(datetime, format_string \\ @default_time_format) |
| 782 | when is_struct(datetime, NaiveDateTime) and is_bitstring(format_string) do | |
| 783 | 5 | Timex.format(datetime, format_string) |
| 784 | end | |
| 785 | ||
| 786 | @doc """ | |
| 787 | Format a `NaiveDateTime` into a human-readable time, displaying hours and minutes by default, in 24-hour time. | |
| 788 | ||
| 789 | ## Arguments | |
| 790 | ||
| 791 | * `datetime`, a valid `NaiveDateTime` structure | |
| 792 | * `format_string`, an optional format string, in `Timex` default formatting language. | |
| 793 | ||
| 794 | ## Returns | |
| 795 | ||
| 796 | * formatted time string, or an exception on error | |
| 797 | ||
| 798 | ## Examples | |
| 799 | ||
| 800 | iex> Klepsidra.TimeTracking.Timer.format_human_readable_time!(~N[2024-01-23 12:34:56]) | |
| 801 | "12:34" | |
| 802 | """ | |
| 803 | @spec format_human_readable_time!(NaiveDateTime.t()) :: bitstring() | |
| 804 | 23 | def format_human_readable_time!(datetime, format_string \\ @default_time_format) |
| 805 | when is_struct(datetime, NaiveDateTime) and is_bitstring(format_string) do | |
| 806 | 22 | Timex.format!(datetime, format_string) |
| 807 | end | |
| 808 | ||
| 809 | @spec calculate_aggregate_duration_for_timers(timers :: [duration_tuple(), ...]) :: %{ | |
| 810 | base_unit_duration: Cldr.Unit.t(), | |
| 811 | duration_in_hours: bitstring(), | |
| 812 | human_readable_duration: bitstring() | nil | |
| 813 | } | |
| 814 | def calculate_aggregate_duration_for_timers(timers) when is_list(timers) do | |
| 815 | timers | |
| 816 | |> convert_durations_to_base_time_unit() | |
| 817 | |> sum_base_unit_durations() | |
| 818 | 0 | |> format_aggregate_duration_for_project() |
| 819 | end | |
| 820 | ||
| 821 | @spec format_aggregate_duration_for_project(base_unit_duration :: Cldr.Unit.t()) :: %{ | |
| 822 | base_unit_duration: Cldr.Unit.t(), | |
| 823 | duration_in_hours: String.t(), | |
| 824 | human_readable_duration: String.t() | nil | |
| 825 | } | |
| 826 | def format_aggregate_duration_for_project(base_unit_duration) | |
| 827 | when is_struct(base_unit_duration, Cldr.Unit) do | |
| 828 | 0 | duration_in_hours = |
| 829 | base_unit_duration | |
| 830 | |> Klepsidra.Cldr.Unit.convert!(:hour_increment) | |
| 831 | 0 | |> then(fn i -> Cldr.Unit.round(i, 1) end) |
| 832 | |> Klepsidra.Cldr.Unit.to_string!() | |
| 833 | ||
| 834 | 0 | duration_in_dhm_format = |
| 835 | format_human_readable_duration(base_unit_duration, | |
| 836 | unit_list: [ | |
| 837 | :day, | |
| 838 | :hour_increment, | |
| 839 | :minute_increment | |
| 840 | ], | |
| 841 | return_if_short_duration: false | |
| 842 | ) | |
| 843 | ||
| 844 | 0 | %{ |
| 845 | base_unit_duration: base_unit_duration, | |
| 846 | duration_in_hours: duration_in_hours, | |
| 847 | human_readable_duration: duration_in_dhm_format | |
| 848 | } | |
| 849 | end | |
| 850 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.Math do | |
| 1 | @moduledoc """ | |
| 2 | Utilities module defining mathematical functionality, commonly used | |
| 3 | all across the project. | |
| 4 | ||
| 5 | This is a supplement to the inbuilt maths fuctions | |
| 6 | in the `Kernel` module, but also `Decimal` and `Cldr.Unit.Math`, | |
| 7 | providing highly specialised functions, working across a range of used | |
| 8 | units. | |
| 9 | """ | |
| 10 | ||
| 11 | use Private | |
| 12 | ||
| 13 | @doc """ | |
| 14 | Calculates the arithmetic mean, usually referred to as the average, | |
| 15 | given a sum and count of items. | |
| 16 | ||
| 17 | As the functionality inevitably depends on division, eager pattern-matching | |
| 18 | is used in function heads, immediately returning zero, preventing divide by | |
| 19 | zero errors. | |
| 20 | ||
| 21 | ## Arguments | |
| 22 | ||
| 23 | * `sum` is any valid `Cldr.Unit`, integer or float, and is the numerator | |
| 24 | * `count` is an integer only, used for a discrete number of events | |
| 25 | ||
| 26 | ## Returns | |
| 27 | ||
| 28 | * The arithmetic mean, in integer or float format | |
| 29 | ||
| 30 | ## Examples | |
| 31 | ||
| 32 | iex> Math.arithmetic_mean(Cldr.Unit.new!(91.0, :second), 13) | |
| 33 | Cldr.Unit.new!(:second, 7) | |
| 34 | ||
| 35 | iex> Math.arithmetic_mean(Cldr.Unit.new!(0, :second), 13) | |
| 36 | Cldr.Unit.new!(:second, 0) | |
| 37 | ||
| 38 | iex> Math.arithmetic_mean(Cldr.Unit.new!(7, :second), 0) | |
| 39 | 0 | |
| 40 | ||
| 41 | iex> Math.arithmetic_mean(13, 2) | |
| 42 | 6.5 | |
| 43 | ||
| 44 | iex> Math.arithmetic_mean(13, 0) | |
| 45 | 0 | |
| 46 | """ | |
| 47 | @spec arithmetic_mean( | |
| 48 | sum :: Cldr.Unit.t(), | |
| 49 | count :: integer() | |
| 50 | ) :: number() | |
| 51 | @spec arithmetic_mean( | |
| 52 | sum :: number() | any(), | |
| 53 | count :: integer() | |
| 54 | ) :: number() | |
| 55 | 102 | def arithmetic_mean(%Cldr.Unit{} = _sum, 0), do: 0 |
| 56 | 3 | def arithmetic_mean(_sum, 0), do: 0 |
| 57 | ||
| 58 | def arithmetic_mean(%Cldr.Unit{} = sum, count) | |
| 59 | when is_integer(count) do | |
| 60 | 104 | multi_unit_div(sum, count) |
| 61 | end | |
| 62 | ||
| 63 | def arithmetic_mean(sum, count) | |
| 64 | when is_number(sum) and is_integer(count) do | |
| 65 | 3 | multi_unit_div(sum, count) |
| 66 | end | |
| 67 | ||
| 68 | @doc """ | |
| 69 | Divides multiple types of units not directly covered in the `Kernel`, | |
| 70 | `Decimal` and `Cldr.Unit` modules. | |
| 71 | ||
| 72 | As division by zero is illegal, eager pattern-matching is used in | |
| 73 | function heads, immediately returning zero or equivalent, preventing | |
| 74 | raising of errors. | |
| 75 | ||
| 76 | ## Arguments | |
| 77 | ||
| 78 | * `numerator`, which is a number, `Decimal`, or `Cldr.Unit` type | |
| 79 | * `denominator`, a number or `Decimal` type | |
| 80 | ||
| 81 | ## Returns | |
| 82 | ||
| 83 | * Result of the division, in float, `Decimal`, or `Cldr.Unit` type | |
| 84 | ||
| 85 | ## Examples | |
| 86 | ||
| 87 | iex> Math.multi_unit_div(300, 13) | |
| 88 | 23.076923076923077 | |
| 89 | """ | |
| 90 | @spec multi_unit_div( | |
| 91 | numerator :: number() | Decimal.t() | Cldr.Unit.t(), | |
| 92 | denominator :: number() | Decimal.t() | |
| 93 | ) :: float() | Decimal.t() | Cldr.Unit.t() | |
| 94 | 201 | def multi_unit_div(%Decimal{} = _numerator, 0), do: Decimal.new(0) |
| 95 | 201 | def multi_unit_div(%Decimal{} = _numerator, +0.0), do: Decimal.new("0.0") |
| 96 | 4 | def multi_unit_div(_numerator, %Decimal{coef: 0}), do: Decimal.new(0) |
| 97 | 202 | def multi_unit_div(_numerator, 0), do: 0.0 |
| 98 | 202 | def multi_unit_div(_numerator, +0.0), do: 0.0 |
| 99 | ||
| 100 | def multi_unit_div(%Cldr.Unit{} = numerator, denominator) | |
| 101 | when is_integer(denominator) or is_struct(denominator, Cldr.Unit) do | |
| 102 | 104 | Cldr.Unit.div!(numerator, denominator) |
| 103 | end | |
| 104 | ||
| 105 | def multi_unit_div(%Decimal{} = numerator, denominator) | |
| 106 | when is_float(denominator) do | |
| 107 | 201 | Decimal.div(numerator, Decimal.from_float(denominator)) |
| 108 | end | |
| 109 | ||
| 110 | def multi_unit_div(%Decimal{} = numerator, denominator) | |
| 111 | when is_integer(denominator) do | |
| 112 | 201 | Decimal.div(numerator, denominator) |
| 113 | end | |
| 114 | ||
| 115 | def multi_unit_div(numerator, %Decimal{} = denominator) | |
| 116 | when is_float(numerator) do | |
| 117 | 2 | Decimal.div(Decimal.from_float(numerator), denominator) |
| 118 | end | |
| 119 | ||
| 120 | def multi_unit_div(numerator, %Decimal{} = denominator) | |
| 121 | when is_integer(numerator) do | |
| 122 | 2 | Decimal.div(numerator, denominator) |
| 123 | end | |
| 124 | ||
| 125 | def multi_unit_div(numerator, denominator) | |
| 126 | when is_number(numerator) and is_number(denominator) do | |
| 127 | 408 | Kernel./(numerator, denominator) |
| 128 | end | |
| 129 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb do | |
| 1 | @moduledoc """ | |
| 2 | The entrypoint for defining your web interface, such | |
| 3 | as controllers, components, channels, and so on. | |
| 4 | ||
| 5 | This can be used in your application as: | |
| 6 | ||
| 7 | use KlepsidraWeb, :controller | |
| 8 | use KlepsidraWeb, :html | |
| 9 | ||
| 10 | The definitions below will be executed for every controller, | |
| 11 | component, etc, so keep them short and clean, focused | |
| 12 | on imports, uses and aliases. | |
| 13 | ||
| 14 | Do NOT define functions inside the quoted expressions | |
| 15 | below. Instead, define additional modules and import | |
| 16 | those modules here. | |
| 17 | """ | |
| 18 | ||
| 19 | 10 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) |
| 20 | ||
| 21 | def router do | |
| 22 | quote do | |
| 23 | use Phoenix.Router, helpers: false | |
| 24 | ||
| 25 | # Import common connection and controller functions to use in pipelines | |
| 26 | import Plug.Conn | |
| 27 | import Phoenix.Controller | |
| 28 | import Phoenix.LiveView.Router | |
| 29 | ||
| 30 | import KlepsidraWeb.CSP, only: [put_content_security_policy: 2] | |
| 31 | end | |
| 32 | end | |
| 33 | ||
| 34 | def channel do | |
| 35 | quote do | |
| 36 | use Phoenix.Channel | |
| 37 | end | |
| 38 | end | |
| 39 | ||
| 40 | def controller do | |
| 41 | quote do | |
| 42 | use Phoenix.Controller, | |
| 43 | formats: [:html, :json], | |
| 44 | layouts: [html: KlepsidraWeb.Layouts] | |
| 45 | ||
| 46 | import Plug.Conn | |
| 47 | use Gettext, backend: KlepsidraWeb.Gettext | |
| 48 | ||
| 49 | unquote(verified_routes()) | |
| 50 | end | |
| 51 | end | |
| 52 | ||
| 53 | def live_view do | |
| 54 | quote do | |
| 55 | use Phoenix.LiveView, | |
| 56 | layout: {KlepsidraWeb.Layouts, :app} | |
| 57 | ||
| 58 | unquote(html_helpers()) | |
| 59 | end | |
| 60 | end | |
| 61 | ||
| 62 | def live_component do | |
| 63 | quote do | |
| 64 | use Phoenix.LiveComponent | |
| 65 | ||
| 66 | unquote(html_helpers()) | |
| 67 | end | |
| 68 | end | |
| 69 | ||
| 70 | def html do | |
| 71 | quote do | |
| 72 | use Phoenix.Component | |
| 73 | ||
| 74 | # Import convenience functions from controllers | |
| 75 | import Phoenix.Controller, | |
| 76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] | |
| 77 | ||
| 78 | import KlepsidraWeb.CSP, | |
| 79 | only: [get_csp_nonce: 0] | |
| 80 | ||
| 81 | # Include general helpers for rendering HTML | |
| 82 | unquote(html_helpers()) | |
| 83 | end | |
| 84 | end | |
| 85 | ||
| 86 | defp html_helpers do | |
| 87 | quote do | |
| 88 | # HTML escaping functionality | |
| 89 | import Phoenix.HTML | |
| 90 | # Core UI components and translation | |
| 91 | import KlepsidraWeb.CoreComponents | |
| 92 | use Gettext, backend: KlepsidraWeb.Gettext | |
| 93 | ||
| 94 | # Shortcut for generating JS commands | |
| 95 | alias Phoenix.LiveView.JS | |
| 96 | ||
| 97 | # Routes generation with the ~p sigil | |
| 98 | unquote(verified_routes()) | |
| 99 | end | |
| 100 | end | |
| 101 | ||
| 102 | def verified_routes do | |
| 103 | quote do | |
| 104 | use Phoenix.VerifiedRoutes, | |
| 105 | endpoint: KlepsidraWeb.Endpoint, | |
| 106 | router: KlepsidraWeb.Router, | |
| 107 | statics: KlepsidraWeb.static_paths() | |
| 108 | end | |
| 109 | end | |
| 110 | ||
| 111 | @doc """ | |
| 112 | When used, dispatch to the appropriate controller/view/etc. | |
| 113 | """ | |
| 114 | defmacro __using__(which) when is_atom(which) do | |
| 115 | 10 | apply(__MODULE__, which, []) |
| 116 | end | |
| 117 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.CoreComponents do | |
| 1 | @moduledoc """ | |
| 2 | Provides core UI components. | |
| 3 | ||
| 4 | At the first glance, this module may seem daunting, but its goal is | |
| 5 | to provide some core building blocks in your application, such modals, | |
| 6 | tables, and forms. The components are mostly markup and well documented | |
| 7 | with doc strings and declarative assigns. You may customize and style | |
| 8 | them in any way you want, based on your application growth and needs. | |
| 9 | ||
| 10 | The default components use Tailwind CSS, a utility-first CSS framework. | |
| 11 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn | |
| 12 | how to customize them or feel free to swap in another framework altogether. | |
| 13 | ||
| 14 | Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. | |
| 15 | """ | |
| 16 | use Phoenix.Component | |
| 17 | ||
| 18 | alias Phoenix.LiveView.JS | |
| 19 | use Gettext, backend: KlepsidraWeb.Gettext | |
| 20 | ||
| 21 | @doc """ | |
| 22 | Renders a modal. | |
| 23 | ||
| 24 | ## Examples | |
| 25 | ||
| 26 | <.modal id="confirm-modal"> | |
| 27 | This is a modal. | |
| 28 | </.modal> | |
| 29 | ||
| 30 | JS commands may be passed to the `:on_cancel` to configure | |
| 31 | the closing/cancel event, for example: | |
| 32 | ||
| 33 | <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> | |
| 34 | This is another modal. | |
| 35 | </.modal> | |
| 36 | ||
| 37 | """ | |
| 38 | attr(:id, :string, required: true) | |
| 39 | attr(:show, :boolean, default: false) | |
| 40 | attr(:on_cancel, JS, default: %JS{}) | |
| 41 | slot(:inner_block, required: true) | |
| 42 | ||
| 43 | def modal(assigns) do | |
| 44 | 19 | ~H""" |
| 45 | 19 | <div |
| 46 | 19 | id={@id} |
| 47 | 19 | phx-mounted={@show && show_modal(@id)} |
| 48 | 19 | phx-remove={hide_modal(@id)} |
| 49 | 19 | data-cancel={JS.exec(@on_cancel, "phx-remove")} |
| 50 | class="relative z-50 hidden" | |
| 51 | > | |
| 52 | 19 | <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" /> |
| 53 | <div | |
| 54 | class="fixed inset-0 overflow-y-auto" | |
| 55 | 19 | aria-labelledby={"#{@id}-title"} |
| 56 | 19 | aria-describedby={"#{@id}-description"} |
| 57 | role="dialog" | |
| 58 | aria-modal="true" | |
| 59 | tabindex="0" | |
| 60 | > | |
| 61 | <div class="flex min-h-full items-center justify-center"> | |
| 62 | <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8"> | |
| 63 | 19 | <.focus_wrap |
| 64 | 19 | id={"#{@id}-container"} |
| 65 | 19 | phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} |
| 66 | phx-key="escape" | |
| 67 | 19 | phx-click-away={JS.exec("data-cancel", to: "##{@id}")} |
| 68 | class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition" | |
| 69 | > | |
| 70 | <div class="absolute top-6 right-5"> | |
| 71 | 19 | <button |
| 72 | 19 | phx-click={JS.exec("data-cancel", to: "##{@id}")} |
| 73 | type="button" | |
| 74 | class="-m-3 flex-none p-3 opacity-20 hover:opacity-40" | |
| 75 | aria-label={gettext("close")} | |
| 76 | > | |
| 77 | 19 | <.icon name="hero-x-mark-solid" class="h-5 w-5" /> |
| 78 | </button> | |
| 79 | </div> | |
| 80 | 19 | <div id={"#{@id}-content"}> |
| 81 | 19 | <%= render_slot(@inner_block) %> |
| 82 | </div> | |
| 83 | </.focus_wrap> | |
| 84 | </div> | |
| 85 | </div> | |
| 86 | </div> | |
| 87 | </div> | |
| 88 | """ | |
| 89 | end | |
| 90 | ||
| 91 | @doc """ | |
| 92 | Renders flash notices. | |
| 93 | ||
| 94 | ## Examples | |
| 95 | ||
| 96 | <.flash kind={:info} flash={@flash} /> | |
| 97 | <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash> | |
| 98 | """ | |
| 99 | attr(:id, :string, default: "flash", doc: "the optional id of flash container") | |
| 100 | attr(:flash, :map, default: %{}, doc: "the map of flash messages to display") | |
| 101 | attr(:title, :string, default: nil) | |
| 102 | attr(:kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup") | |
| 103 | attr(:rest, :global, doc: "the arbitrary HTML attributes to add to the flash container") | |
| 104 | ||
| 105 | slot(:inner_block, doc: "the optional inner block that renders the flash message") | |
| 106 | ||
| 107 | def flash(assigns) do | |
| 108 | 0 | ~H""" |
| 109 | 0 | <div |
| 110 | 0 | :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} |
| 111 | 0 | id={@id} |
| 112 | 0 | phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} |
| 113 | role="alert" | |
| 114 | class={[ | |
| 115 | "fixed top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1", | |
| 116 | 0 | @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900", |
| 117 | 0 | @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900" |
| 118 | ]} | |
| 119 | 0 | {@rest} |
| 120 | > | |
| 121 | 0 | <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6"> |
| 122 | 0 | <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" /> |
| 123 | 0 | <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" /> |
| 124 | 0 | <%= @title %> |
| 125 | </p> | |
| 126 | <p class="mt-2 text-sm leading-5"><%= msg %></p> | |
| 127 | 0 | <button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}> |
| 128 | 0 | <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" /> |
| 129 | </button> | |
| 130 | </div> | |
| 131 | """ | |
| 132 | end | |
| 133 | ||
| 134 | @doc """ | |
| 135 | Shows the flash group with standard titles and content. | |
| 136 | ||
| 137 | ## Examples | |
| 138 | ||
| 139 | <.flash_group flash={@flash} /> | |
| 140 | """ | |
| 141 | attr(:flash, :map, required: true, doc: "the map of flash messages") | |
| 142 | ||
| 143 | def flash_group(assigns) do | |
| 144 | 0 | ~H""" |
| 145 | 0 | <.flash kind={:info} title="Success!" flash={@flash} /> |
| 146 | 0 | <.flash kind={:error} title="Error!" flash={@flash} /> |
| 147 | 0 | <.flash |
| 148 | id="disconnected" | |
| 149 | kind={:error} | |
| 150 | title="We can't find the internet" | |
| 151 | phx-disconnected={show("#disconnected")} | |
| 152 | phx-connected={hide("#disconnected")} | |
| 153 | hidden | |
| 154 | > | |
| 155 | 0 | Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" /> |
| 156 | </.flash> | |
| 157 | """ | |
| 158 | end | |
| 159 | ||
| 160 | @doc """ | |
| 161 | Renders a simple form. | |
| 162 | ||
| 163 | ## Examples | |
| 164 | ||
| 165 | <.simple_form for={@form} phx-change="validate" phx-submit="save"> | |
| 166 | <.input field={@form[:email]} label="Email"/> | |
| 167 | <.input field={@form[:username]} label="Username" /> | |
| 168 | <:actions> | |
| 169 | <.button>Save</.button> | |
| 170 | </:actions> | |
| 171 | </.simple_form> | |
| 172 | """ | |
| 173 | attr(:for, :any, required: true, doc: "the datastructure for the form") | |
| 174 | attr(:as, :any, default: nil, doc: "the server side parameter to collect all input under") | |
| 175 | ||
| 176 | attr(:rest, :global, | |
| 177 | include: ~w(autocomplete name rel action enctype method novalidate target), | |
| 178 | doc: "the arbitrary HTML attributes to apply to the form tag" | |
| 179 | ) | |
| 180 | ||
| 181 | slot(:inner_block, required: true) | |
| 182 | slot(:actions, doc: "the slot for form actions, such as a submit button") | |
| 183 | ||
| 184 | def simple_form(assigns) do | |
| 185 | 41 | ~H""" |
| 186 | 41 | <.form :let={f} for={@for} as={@as} {@rest}> |
| 187 | <div class="mt-10 space-y-8 bg-white"> | |
| 188 | 41 | <%= render_slot(@inner_block, f) %> |
| 189 | 41 | <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6"> |
| 190 | <%= render_slot(action, f) %> | |
| 191 | </div> | |
| 192 | </div> | |
| 193 | </.form> | |
| 194 | """ | |
| 195 | end | |
| 196 | ||
| 197 | @doc """ | |
| 198 | Renders a button. | |
| 199 | ||
| 200 | ## Examples | |
| 201 | ||
| 202 | <.button>Send!</.button> | |
| 203 | <.button phx-click="go" class="ml-2">Send!</.button> | |
| 204 | """ | |
| 205 | attr(:type, :string, default: nil) | |
| 206 | attr(:class, :string, default: nil) | |
| 207 | attr(:rest, :global, include: ~w(disabled form name value)) | |
| 208 | ||
| 209 | slot(:inner_block, required: true) | |
| 210 | ||
| 211 | def button(assigns) do | |
| 212 | 218 | ~H""" |
| 213 | 218 | <button |
| 214 | 218 | type={@type} |
| 215 | class={[ | |
| 216 | "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3", | |
| 217 | "text-sm font-semibold leading-6 text-white active:text-white/80", | |
| 218 | 218 | @class |
| 219 | ]} | |
| 220 | 218 | {@rest} |
| 221 | > | |
| 222 | 218 | <%= render_slot(@inner_block) %> |
| 223 | </button> | |
| 224 | """ | |
| 225 | end | |
| 226 | ||
| 227 | @doc """ | |
| 228 | Renders an input with label and error messages. | |
| 229 | ||
| 230 | A `%Phoenix.HTML.Form{}` and field name may be passed to the input | |
| 231 | to build input names and error messages, or all the attributes and | |
| 232 | errors may be passed explicitly. | |
| 233 | ||
| 234 | ## Examples | |
| 235 | ||
| 236 | <.input field={@form[:email]} type="email" /> | |
| 237 | <.input name="my-input" errors={["oh no!"]} /> | |
| 238 | """ | |
| 239 | attr(:id, :any, default: nil) | |
| 240 | attr(:name, :any) | |
| 241 | attr(:label, :string, default: nil) | |
| 242 | attr(:value, :any) | |
| 243 | ||
| 244 | attr(:type, :string, | |
| 245 | default: "text", | |
| 246 | values: ~w(checkbox color date datetime-local email file hidden month number password | |
| 247 | range radio search select tel text textarea time url week) | |
| 248 | ) | |
| 249 | ||
| 250 | attr(:field, Phoenix.HTML.FormField, | |
| 251 | doc: "a form field struct retrieved from the form, for example: @form[:email]" | |
| 252 | ) | |
| 253 | ||
| 254 | attr(:errors, :list, default: []) | |
| 255 | attr(:checked, :boolean, doc: "the checked flag for checkbox inputs") | |
| 256 | attr(:prompt, :string, default: nil, doc: "the prompt for select inputs") | |
| 257 | attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2") | |
| 258 | attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs") | |
| 259 | ||
| 260 | attr(:rest, :global, | |
| 261 | include: ~w(autocomplete cols disabled form list max maxlength min minlength | |
| 262 | pattern placeholder readonly required rows size step) | |
| 263 | ) | |
| 264 | ||
| 265 | slot(:inner_block) | |
| 266 | ||
| 267 | def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do | |
| 268 | assigns | |
| 269 | 147 | |> assign(field: nil, id: assigns.id || field.id) |
| 270 | 147 | |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) |
| 271 | 147 | |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) |
| 272 | 132 | |> assign_new(:value, fn -> field.value end) |
| 273 | 147 | |> input() |
| 274 | end | |
| 275 | ||
| 276 | def input(%{type: "checkbox", value: value} = assigns) do | |
| 277 | 13 | assigns = |
| 278 | 13 | assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end) |
| 279 | ||
| 280 | 13 | ~H""" |
| 281 | 13 | <div phx-feedback-for={@name}> |
| 282 | <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600"> | |
| 283 | 13 | <input type="hidden" name={@name} value="false" /> |
| 284 | 13 | <input |
| 285 | type="checkbox" | |
| 286 | 13 | id={@id} |
| 287 | 13 | name={@name} |
| 288 | value="true" | |
| 289 | 13 | checked={@checked} |
| 290 | class="rounded border-zinc-300 text-zinc-900 focus:ring-0" | |
| 291 | 13 | {@rest} |
| 292 | /> | |
| 293 | 7 | <%= @label %> |
| 294 | </label> | |
| 295 | 13 | <.error :for={msg <- @errors}><%= msg %></.error> |
| 296 | </div> | |
| 297 | """ | |
| 298 | end | |
| 299 | ||
| 300 | def input(%{type: "select"} = assigns) do | |
| 301 | 10 | ~H""" |
| 302 | 10 | <div phx-feedback-for={@name}> |
| 303 | 10 | <.label for={@id}><%= @label %></.label> |
| 304 | 10 | <select |
| 305 | 10 | id={@id} |
| 306 | 10 | name={@name} |
| 307 | class="mt-1 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" | |
| 308 | 10 | multiple={@multiple} |
| 309 | 10 | {@rest} |
| 310 | > | |
| 311 | 10 | <option :if={@prompt} value=""><%= @prompt %></option> |
| 312 | 10 | <%= Phoenix.HTML.Form.options_for_select(@options, @value) %> |
| 313 | </select> | |
| 314 | 10 | <.error :for={msg <- @errors}><%= msg %></.error> |
| 315 | </div> | |
| 316 | """ | |
| 317 | end | |
| 318 | ||
| 319 | def input(%{type: "textarea"} = assigns) do | |
| 320 | 25 | ~H""" |
| 321 | 25 | <div phx-feedback-for={@name}> |
| 322 | 25 | <.label for={@id}><%= @label %></.label> |
| 323 | 25 | <textarea |
| 324 | 25 | id={@id} |
| 325 | 25 | name={@name} |
| 326 | class={[ | |
| 327 | "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6", | |
| 328 | "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400", | |
| 329 | "min-h-[6rem] border-zinc-300 focus:border-zinc-400", | |
| 330 | 25 | @errors != [] && "border-rose-400 focus:border-rose-400" |
| 331 | ]} | |
| 332 | 25 | {@rest} |
| 333 | 25 | ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea> |
| 334 | 25 | <.error :for={msg <- @errors}><%= msg %></.error> |
| 335 | </div> | |
| 336 | """ | |
| 337 | end | |
| 338 | ||
| 339 | # All other inputs text, datetime-local, url, password, etc. are handled here... | |
| 340 | def input(assigns) do | |
| 341 | 99 | ~H""" |
| 342 | 99 | <div phx-feedback-for={@name}> |
| 343 | 99 | <.label for={@id}><%= @label %></.label> |
| 344 | 55 | <input |
| 345 | 55 | type={@type} |
| 346 | 99 | name={@name} |
| 347 | 99 | id={@id} |
| 348 | 95 | value={Phoenix.HTML.Form.normalize_value(@type, @value)} |
| 349 | class={[ | |
| 350 | "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6", | |
| 351 | "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400", | |
| 352 | "border-zinc-300 focus:border-zinc-400", | |
| 353 | 99 | @errors != [] && "border-rose-400 focus:border-rose-400" |
| 354 | ]} | |
| 355 | 99 | {@rest} |
| 356 | /> | |
| 357 | 99 | <.error :for={msg <- @errors}><%= msg %></.error> |
| 358 | </div> | |
| 359 | """ | |
| 360 | end | |
| 361 | ||
| 362 | @doc """ | |
| 363 | Renders a label. | |
| 364 | """ | |
| 365 | attr(:for, :string, default: nil) | |
| 366 | slot(:inner_block, required: true) | |
| 367 | ||
| 368 | def label(assigns) do | |
| 369 | 149 | ~H""" |
| 370 | 149 | <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800"> |
| 371 | 89 | <%= render_slot(@inner_block) %> |
| 372 | </label> | |
| 373 | """ | |
| 374 | end | |
| 375 | ||
| 376 | @doc """ | |
| 377 | Generates a generic error message. | |
| 378 | """ | |
| 379 | slot(:inner_block, required: true) | |
| 380 | ||
| 381 | def error(assigns) do | |
| 382 | 29 | ~H""" |
| 383 | <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden"> | |
| 384 | 29 | <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> |
| 385 | 29 | <%= render_slot(@inner_block) %> |
| 386 | </p> | |
| 387 | """ | |
| 388 | end | |
| 389 | ||
| 390 | @doc """ | |
| 391 | Renders a header with title. | |
| 392 | """ | |
| 393 | attr(:class, :string, default: nil) | |
| 394 | ||
| 395 | slot(:inner_block, required: true) | |
| 396 | slot(:subtitle) | |
| 397 | slot(:actions) | |
| 398 | ||
| 399 | def header(assigns) do | |
| 400 | 102 | ~H""" |
| 401 | 102 | <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}> |
| 402 | <div> | |
| 403 | <h1 class="text-lg font-semibold leading-8 text-zinc-800"> | |
| 404 | 101 | <%= render_slot(@inner_block) %> |
| 405 | </h1> | |
| 406 | 98 | <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> |
| 407 | 20 | <%= render_slot(@subtitle) %> |
| 408 | </p> | |
| 409 | </div> | |
| 410 | 102 | <div class="flex-none"><%= render_slot(@actions) %></div> |
| 411 | </header> | |
| 412 | """ | |
| 413 | end | |
| 414 | ||
| 415 | @doc ~S""" | |
| 416 | Renders a table with generic styling. | |
| 417 | ||
| 418 | ## Examples | |
| 419 | ||
| 420 | <.table id="users" rows={@users}> | |
| 421 | <:col :let={user} label="id"><%= user.id %></:col> | |
| 422 | <:col :let={user} label="username"><%= user.username %></:col> | |
| 423 | </.table> | |
| 424 | """ | |
| 425 | attr(:id, :string, required: true) | |
| 426 | attr(:rows, :list, required: true) | |
| 427 | attr(:row_id, :any, default: nil, doc: "the function for generating the row id") | |
| 428 | attr(:row_click, :any, default: nil, doc: "the function for handling phx-click on each row") | |
| 429 | ||
| 430 | attr(:row_item, :any, | |
| 431 | default: &Function.identity/1, | |
| 432 | doc: "the function for mapping each row before calling the :col and :action slots" | |
| 433 | ) | |
| 434 | ||
| 435 | slot :col, required: true do | |
| 436 | attr(:label, :string) | |
| 437 | attr(:class, :string, required: false) | |
| 438 | end | |
| 439 | ||
| 440 | slot(:action, doc: "the slot for showing user actions in the last table column") | |
| 441 | ||
| 442 | def table(assigns) do | |
| 443 | 64 | assigns = |
| 444 | 0 | with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do |
| 445 | 64 | assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) |
| 446 | end | |
| 447 | ||
| 448 | 64 | ~H""" |
| 449 | <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0"> | |
| 450 | <table class="w-[40rem] mt-11 sm:w-full"> | |
| 451 | <thead class="text-sm text-left leading-6 text-zinc-500"> | |
| 452 | <tr> | |
| 453 | 52 | <th :for={col <- @col} class="p-0 pr-6 pb-4 font-normal"><%= col[:label] %></th> |
| 454 | 52 | <th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th> |
| 455 | </tr> | |
| 456 | </thead> | |
| 457 | 52 | <tbody |
| 458 | 52 | id={@id} |
| 459 | 64 | phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"} |
| 460 | class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700" | |
| 461 | > | |
| 462 | <tr | |
| 463 | 64 | :for={row <- @rows} |
| 464 | 58 | id={@row_id && @row_id.(row)} |
| 465 | class="group hover:bg-violet-50 hover:bg-opacity-25" | |
| 466 | > | |
| 467 | <td | |
| 468 | 58 | :for={{col, i} <- Enum.with_index(@col)} |
| 469 | 215 | phx-click={@row_click && @row_click.(row)} |
| 470 | 215 | class={["relative p-0", @row_click && "hover:cursor-pointer", col[:class]]} |
| 471 | > | |
| 472 | <div class="block py-4 pr-6"> | |
| 473 | <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-violet-50 group-hover:bg-opacity-25 sm:rounded-l-xl" /> | |
| 474 | 215 | <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}> |
| 475 | 215 | <%= render_slot(col, @row_item.(row)) %> |
| 476 | </span> | |
| 477 | </div> | |
| 478 | </td> | |
| 479 | 58 | <td :if={@action != []} class="relative w-14 p-0"> |
| 480 | <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium"> | |
| 481 | <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-violet-50 group-hover:bg-opacity-25 sm:rounded-r-xl" /> | |
| 482 | <span | |
| 483 | 57 | :for={action <- @action} |
| 484 | class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700" | |
| 485 | > | |
| 486 | 114 | <%= render_slot(action, @row_item.(row)) %> |
| 487 | </span> | |
| 488 | </div> | |
| 489 | </td> | |
| 490 | </tr> | |
| 491 | </tbody> | |
| 492 | </table> | |
| 493 | </div> | |
| 494 | """ | |
| 495 | end | |
| 496 | ||
| 497 | @doc """ | |
| 498 | Renders a data list. | |
| 499 | ||
| 500 | ## Examples | |
| 501 | ||
| 502 | <.list> | |
| 503 | <:item title="Title"><%= @post.title %></:item> | |
| 504 | <:item title="Views"><%= @post.views %></:item> | |
| 505 | </.list> | |
| 506 | """ | |
| 507 | slot :item, required: true do | |
| 508 | attr(:title, :string, required: true) | |
| 509 | end | |
| 510 | ||
| 511 | def list(assigns) do | |
| 512 | 31 | ~H""" |
| 513 | <div class="mt-14"> | |
| 514 | <dl class="-my-4 divide-y divide-zinc-100"> | |
| 515 | 31 | <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> |
| 516 | 87 | <dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt> |
| 517 | <dd class="text-zinc-700"><%= render_slot(item) %></dd> | |
| 518 | </div> | |
| 519 | </dl> | |
| 520 | </div> | |
| 521 | """ | |
| 522 | end | |
| 523 | ||
| 524 | @doc """ | |
| 525 | Renders a back navigation link. | |
| 526 | ||
| 527 | ## Examples | |
| 528 | ||
| 529 | <.back navigate={~p"/posts"}>Back to posts</.back> | |
| 530 | """ | |
| 531 | attr(:navigate, :any, required: true) | |
| 532 | slot(:inner_block, required: true) | |
| 533 | ||
| 534 | def back(assigns) do | |
| 535 | 28 | ~H""" |
| 536 | <div class="mt-16"> | |
| 537 | 28 | <.link |
| 538 | 28 | navigate={@navigate} |
| 539 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" | |
| 540 | > | |
| 541 | 28 | <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> |
| 542 | 28 | <%= render_slot(@inner_block) %> |
| 543 | </.link> | |
| 544 | </div> | |
| 545 | """ | |
| 546 | end | |
| 547 | ||
| 548 | @doc """ | |
| 549 | Renders a [Hero Icon](https://heroicons.com). | |
| 550 | ||
| 551 | Hero icons come in three styles – outline, solid, and mini. | |
| 552 | By default, the outline style is used, but solid an mini may | |
| 553 | be applied by using the `-solid` and `-mini` suffix. | |
| 554 | ||
| 555 | You can customize the size and colors of the icons by setting | |
| 556 | width, height, and background color classes. | |
| 557 | ||
| 558 | Icons are extracted from your `assets/vendor/heroicons` directory and bundled | |
| 559 | within your compiled app.css by the plugin in your `assets/tailwind.config.js`. | |
| 560 | ||
| 561 | ## Examples | |
| 562 | ||
| 563 | <.icon name="hero-x-mark-solid" /> | |
| 564 | <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> | |
| 565 | """ | |
| 566 | attr(:name, :string, required: true) | |
| 567 | attr(:class, :string, default: nil) | |
| 568 | attr(:rest, :global) | |
| 569 | ||
| 570 | def icon(%{name: "hero-" <> _} = assigns) do | |
| 571 | 76 | ~H""" |
| 572 | 76 | <span class={[@name, @class]} {@rest} /> |
| 573 | """ | |
| 574 | end | |
| 575 | ||
| 576 | ## JS Commands | |
| 577 | ||
| 578 | def show(js \\ %JS{}, selector) do | |
| 579 | 19 | JS.show(js, |
| 580 | to: selector, | |
| 581 | transition: | |
| 582 | {"transition-all transform ease-out duration-300", | |
| 583 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", | |
| 584 | "opacity-100 translate-y-0 sm:scale-100"} | |
| 585 | ) | |
| 586 | end | |
| 587 | ||
| 588 | def hide(js \\ %JS{}, selector) do | |
| 589 | 76 | JS.hide(js, |
| 590 | to: selector, | |
| 591 | time: 200, | |
| 592 | transition: | |
| 593 | {"transition-all transform ease-in duration-200", | |
| 594 | "opacity-100 translate-y-0 sm:scale-100", | |
| 595 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} | |
| 596 | ) | |
| 597 | end | |
| 598 | ||
| 599 | def show_modal(js \\ %JS{}, id) when is_binary(id) do | |
| 600 | js | |
| 601 | 19 | |> JS.show(to: "##{id}") |
| 602 | |> JS.show( | |
| 603 | 19 | to: "##{id}-bg", |
| 604 | transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} | |
| 605 | ) | |
| 606 | 19 | |> show("##{id}-container") |
| 607 | |> JS.add_class("overflow-hidden", to: "body") | |
| 608 | 19 | |> JS.focus_first(to: "##{id}-content") |
| 609 | end | |
| 610 | ||
| 611 | def hide_modal(js \\ %JS{}, id) do | |
| 612 | js | |
| 613 | |> JS.hide( | |
| 614 | 19 | to: "##{id}-bg", |
| 615 | transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} | |
| 616 | ) | |
| 617 | 19 | |> hide("##{id}-container") |
| 618 | 19 | |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) |
| 619 | |> JS.remove_class("overflow-hidden", to: "body") | |
| 620 | 19 | |> JS.pop_focus() |
| 621 | end | |
| 622 | ||
| 623 | @doc """ | |
| 624 | Translates an error message using gettext. | |
| 625 | """ | |
| 626 | def translate_error({msg, opts}) do | |
| 627 | # When using gettext, we typically pass the strings we want | |
| 628 | # to translate as a static argument: | |
| 629 | # | |
| 630 | # # Translate the number of files with plural rules | |
| 631 | # dngettext("errors", "1 file", "%{count} files", count) | |
| 632 | # | |
| 633 | # However the error messages in our forms and APIs are generated | |
| 634 | # dynamically, so we need to translate them by calling Gettext | |
| 635 | # with our gettext backend as first argument. Translations are | |
| 636 | # available in the errors.po file (as we use the "errors" domain). | |
| 637 | 29 | if count = opts[:count] do |
| 638 | 0 | Gettext.dngettext(KlepsidraWeb.Gettext, "errors", msg, msg, count, opts) |
| 639 | else | |
| 640 | 29 | Gettext.dgettext(KlepsidraWeb.Gettext, "errors", msg, opts) |
| 641 | end | |
| 642 | end | |
| 643 | ||
| 644 | @doc """ | |
| 645 | Translates the errors for a field from a keyword list of errors. | |
| 646 | """ | |
| 647 | def translate_errors(errors, field) when is_list(errors) do | |
| 648 | 0 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) |
| 649 | end | |
| 650 | ||
| 651 | def live_select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do | |
| 652 | 15 | assigns = |
| 653 | assigns | |
| 654 | 15 | |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) |
| 655 | |> assign(:live_select_opts, assigns_to_attributes(assigns, [:errors, :label])) | |
| 656 | ||
| 657 | 15 | ~H""" |
| 658 | 15 | <div phx-feedback-for={@field.name}> |
| 659 | 15 | <.label for={@field.id}><%= @label %></.label> |
| 660 | 15 | <LiveSelect.live_select |
| 661 | 15 | field={@field} |
| 662 | active_option_class={["bg-violet-700 text-white"]} | |
| 663 | available_option_class={["cursor-pointer hover:bg-violet-500 hover:text-white rounded"]} | |
| 664 | clear_button_class={["cursor-pointer hidden"]} | |
| 665 | option_class={["px-4 py-1 rounded"]} | |
| 666 | selected_option_class={["bg-violet-400"]} | |
| 667 | text_input_class={[ | |
| 668 | "mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]", | |
| 669 | "text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6", | |
| 670 | "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5", | |
| 671 | "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5", | |
| 672 | 15 | @errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10" |
| 673 | ]} | |
| 674 | 15 | {@live_select_opts} |
| 675 | /> | |
| 676 | ||
| 677 | 15 | <.error :for={msg <- @errors}><%= msg %></.error> |
| 678 | </div> | |
| 679 | """ | |
| 680 | end | |
| 681 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.Layouts do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :html | |
| 4 | ||
| 5 | # Changed from embed_templates "layouts/*" for web LiveView only | |
| 6 | embed_templates "layouts/*.html" | |
| 7 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ErrorHTML do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :html | |
| 4 | ||
| 5 | # If you want to customize your error pages, | |
| 6 | # uncomment the embed_templates/1 call below | |
| 7 | # and add pages to the error directory: | |
| 8 | # | |
| 9 | # * lib/klepsidra_web/controllers/error_html/404.html.heex | |
| 10 | # * lib/klepsidra_web/controllers/error_html/500.html.heex | |
| 11 | # | |
| 12 | # embed_templates "error_html/*" | |
| 13 | ||
| 14 | # The default is to render a plain text page based on | |
| 15 | # the template name. For example, "404.html" becomes | |
| 16 | # "Not Found". | |
| 17 | def render(template, _assigns) do | |
| 18 | 2 | Phoenix.Controller.status_message_from_template(template) |
| 19 | end | |
| 20 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ErrorJSON do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | # If you want to customize a particular status code, | |
| 4 | # you may add your own clauses, such as: | |
| 5 | # | |
| 6 | # def render("500.json", _assigns) do | |
| 7 | # %{errors: %{detail: "Internal Server Error"}} | |
| 8 | # end | |
| 9 | ||
| 10 | # By default, Phoenix returns the status message from | |
| 11 | # the template name. For example, "404.json" becomes | |
| 12 | # "Not Found". | |
| 13 | def render(template, _assigns) do | |
| 14 | 2 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} |
| 15 | end | |
| 16 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.PageHTML do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :html | |
| 4 | ||
| 5 | embed_templates "page_html/*" | |
| 6 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.Endpoint do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use Phoenix.Endpoint, otp_app: :klepsidra | |
| 4 | ||
| 5 | # The session will be stored in the cookie and signed, | |
| 6 | # this means its contents can be read but not tampered with. | |
| 7 | # Set :encryption_salt if you would also like to encrypt it. | |
| 8 | @session_options [ | |
| 9 | store: :cookie, | |
| 10 | key: "_klepsidra_key", | |
| 11 | signing_salt: "tTGt4N0Z", | |
| 12 | same_site: "Lax" | |
| 13 | ] | |
| 14 | ||
| 15 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] | |
| 16 | ||
| 17 | # Serve at "/" the static files from "priv/static" directory. | |
| 18 | # | |
| 19 | # You should set gzip to true if you are running phx.digest | |
| 20 | # when deploying your static files in production. | |
| 21 | plug Plug.Static, | |
| 22 | at: "/", | |
| 23 | from: :klepsidra, | |
| 24 | gzip: false, | |
| 25 | only: KlepsidraWeb.static_paths() | |
| 26 | ||
| 27 | # Code reloading can be explicitly enabled under the | |
| 28 | # :code_reloader configuration of your endpoint. | |
| 29 | if code_reloading? do | |
| 30 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket | |
| 31 | plug Phoenix.LiveReloader | |
| 32 | plug Phoenix.CodeReloader | |
| 33 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :klepsidra | |
| 34 | end | |
| 35 | ||
| 36 | plug Phoenix.LiveDashboard.RequestLogger, | |
| 37 | param_key: "request_logger", | |
| 38 | cookie_key: "request_logger" | |
| 39 | ||
| 40 | plug Plug.RequestId | |
| 41 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] | |
| 42 | ||
| 43 | plug Plug.Parsers, | |
| 44 | parsers: [:urlencoded, :multipart, :json], | |
| 45 | pass: ["*/*"], | |
| 46 | json_decoder: Phoenix.json_library() | |
| 47 | ||
| 48 | plug Plug.MethodOverride | |
| 49 | plug Plug.Head | |
| 50 | plug Plug.Session, @session_options | |
| 51 | plug KlepsidraWeb.Router | |
| 52 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.Gettext do | |
| 1 | @moduledoc """ | |
| 2 | A module providing Internationalization with a gettext-based API. | |
| 3 | ||
| 4 | By using [Gettext](https://hexdocs.pm/gettext), | |
| 5 | your module gains a set of macros for translations, for example: | |
| 6 | ||
| 7 | import KlepsidraWeb.Gettext | |
| 8 | ||
| 9 | # Simple translation | |
| 10 | gettext("Here is the string to translate") | |
| 11 | ||
| 12 | # Plural translation | |
| 13 | ngettext("Here is the string to translate", | |
| 14 | "Here are the strings to translate", | |
| 15 | 3) | |
| 16 | ||
| 17 | # Domain-based translation | |
| 18 | dgettext("errors", "Here is the error message to translate") | |
| 19 | ||
| 20 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. | |
| 21 | """ | |
| 22 | use Gettext.Backend, otp_app: :klepsidra | |
| 23 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.CSP do | |
| 1 | @moduledoc """ | |
| 2 | Module that includes Plug and LiveView helpers to handle Content Security | |
| 3 | Policy header. | |
| 4 | ||
| 5 | For inline `<style>` and `<script>` tags, nonce should be used. When the REST | |
| 6 | request is processed a nonce is added to the process dictionary. This ensures | |
| 7 | the nonce stays the same throughout the call, as the nonce in the tags must | |
| 8 | match the nonce in the header. | |
| 9 | ||
| 10 | To allow for inline `<style>` and/or `<script>` tag you must set a `'nonce'` | |
| 11 | source. | |
| 12 | ||
| 13 | ## Set up | |
| 14 | ||
| 15 | To set up MyAppWeb.CSP in your app you must: | |
| 16 | ||
| 17 | ### 1) Configure `lib/my_app_web.ex` | |
| 18 | ||
| 19 | Ensure you import the helpers in `MyAppWeb`. | |
| 20 | ||
| 21 | def router do | |
| 22 | quote do | |
| 23 | use Phoenix.Router, helpers: false | |
| 24 | ||
| 25 | # Import common connection and controller functions to use in pipelines | |
| 26 | import Plug.Conn | |
| 27 | import Phoenix.Controller | |
| 28 | import Phoenix.LiveView.Router | |
| 29 | ||
| 30 | import MyAppWeb.CSP, only: [put_content_security_policy: 2] | |
| 31 | end | |
| 32 | end | |
| 33 | ||
| 34 | # ... | |
| 35 | ||
| 36 | def html do | |
| 37 | quote do | |
| 38 | use Phoenix.Component | |
| 39 | ||
| 40 | import MyAppWeb.CldrHelpers | |
| 41 | ||
| 42 | # Import convenience functions from controllers | |
| 43 | import Phoenix.Controller, | |
| 44 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] | |
| 45 | ||
| 46 | import MyAppWeb.CSP, | |
| 47 | only: [get_csp_nonce: 0] | |
| 48 | ||
| 49 | # Include general helpers for rendering HTML | |
| 50 | unquote(html_helpers()) | |
| 51 | end | |
| 52 | end | |
| 53 | ||
| 54 | # ... | |
| 55 | ||
| 56 | def live_view do | |
| 57 | quote do | |
| 58 | use Phoenix.LiveView, | |
| 59 | layout: {MyAppWeb.Layouts, :app} | |
| 60 | ||
| 61 | on_mount MyAppWeb.CSP | |
| 62 | ||
| 63 | unquote(html_helpers()) | |
| 64 | end | |
| 65 | end | |
| 66 | ||
| 67 | ### 2) Add nonce metatag to the HTML document | |
| 68 | ||
| 69 | Add the following metatag head to | |
| 70 | `lib/my_app_web/components/layouts/root.html.heex`. | |
| 71 | ||
| 72 | <meta name="csp-nonce" content={get_csp_nonce()} /> | |
| 73 | ||
| 74 | ### 3) Pass the CSP nonce to the LiveView socket | |
| 75 | ||
| 76 | Ensure you pass on the CSP nonce to the LiveView socket in | |
| 77 | `assets/js/app.js`. | |
| 78 | ||
| 79 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); | |
| 80 | let cspNonce = document.querySelector("meta[name='csp-nonce']").getAttribute("content") | |
| 81 | let liveSocket = new LiveSocket("/live", Socket, { | |
| 82 | longPollFallbackMs: 2500, | |
| 83 | params: { _csrf_token: csrfToken, _csp_nonce: cspNonce } | |
| 84 | }) | |
| 85 | ||
| 86 | ## Usage | |
| 87 | ||
| 88 | If you got inline `<style>` or script tags you must set the nonce attribute: | |
| 89 | ||
| 90 | <style nonce={get_csp_nonce()}> | |
| 91 | // ... | |
| 92 | </style> | |
| 93 | """ | |
| 94 | require Logger | |
| 95 | ||
| 96 | import Plug.Conn | |
| 97 | ||
| 98 | @doc """ | |
| 99 | Sets a content security policy header. | |
| 100 | ||
| 101 | By default the policy is `default-src 'self'`. `'nonce'` source will be | |
| 102 | expanded with an auto-generated nonce that is persisted in the process | |
| 103 | dictionary. | |
| 104 | ||
| 105 | The options can be a function or a keyword list. Sources can be a binary | |
| 106 | or list of binaries. Duplicate directives will be merged together. | |
| 107 | ||
| 108 | ## Example | |
| 109 | ||
| 110 | plug :put_content_security_policy, | |
| 111 | img_src: "'self' data:`, | |
| 112 | style_src: "'self' 'nonce'" | |
| 113 | ||
| 114 | plug :put_content_security_policy, | |
| 115 | img_src: [ | |
| 116 | "'self'", | |
| 117 | "data:" | |
| 118 | ] | |
| 119 | ||
| 120 | plug :put_content_security_policy, &MyAppWeb.CSPPolicy.opts/1 | |
| 121 | """ | |
| 122 | def put_content_security_policy(conn, fun) when is_function(fun, 1) do | |
| 123 | 0 | put_content_security_policy(conn, fun.(conn)) |
| 124 | end | |
| 125 | ||
| 126 | def put_content_security_policy(conn, opts) when is_list(opts) do | |
| 127 | 39 | csp = |
| 128 | opts | |
| 129 | |> Keyword.has_key?(:default_src) | |
| 130 | |> case do | |
| 131 | 0 | false -> [default_src: "'self'"] ++ opts |
| 132 | 39 | true -> opts |
| 133 | end | |
| 134 | |> Enum.reduce([], fn {name, sources}, acc -> | |
| 135 | 273 | sources = List.wrap(sources) |
| 136 | ||
| 137 | 273 | Keyword.update(acc, name, sources, &(&1 ++ sources)) |
| 138 | end) | |
| 139 | |> Enum.reduce("", fn {name, sources}, acc -> | |
| 140 | 273 | name = String.replace(to_string(name), "_", "-") |
| 141 | ||
| 142 | 273 | sources = |
| 143 | sources | |
| 144 | |> Enum.uniq() | |
| 145 | |> Enum.join(" ") | |
| 146 | 273 | |> String.replace("'nonce'", "'nonce-#{get_csp_nonce()}'") |
| 147 | ||
| 148 | 273 | "#{acc}#{name} #{sources};" |
| 149 | end) | |
| 150 | ||
| 151 | 39 | put_resp_header(conn, "content-security-policy", csp) |
| 152 | end | |
| 153 | ||
| 154 | @doc """ | |
| 155 | Gets the CSP nonce. | |
| 156 | ||
| 157 | Generates a nonce and stores it in the process dictionary if one does not exist. | |
| 158 | """ | |
| 159 | def get_csp_nonce do | |
| 160 | 351 | if nonce = Process.get(:plug_csp_nonce) do |
| 161 | 312 | nonce |
| 162 | else | |
| 163 | 39 | nonce = csp_nonce() |
| 164 | 39 | Process.put(:plug_csp_nonce, nonce) |
| 165 | 39 | nonce |
| 166 | end | |
| 167 | end | |
| 168 | ||
| 169 | defp csp_nonce do | |
| 170 | 24 | |
| 171 | |> :crypto.strong_rand_bytes() | |
| 172 | 39 | |> Base.encode64(padding: false) |
| 173 | end | |
| 174 | ||
| 175 | @doc """ | |
| 176 | Loads the CSP nonce into the LiveView process. | |
| 177 | """ | |
| 178 | def on_mount( | |
| 179 | :default, | |
| 180 | _params, | |
| 181 | _session, | |
| 182 | %{private: %{connect_params: %{"_csp_nonce" => nonce}}} = socket | |
| 183 | ) do | |
| 184 | 0 | Process.put(:plug_csp_nonce, nonce) |
| 185 | ||
| 186 | {:cont, socket} | |
| 187 | end | |
| 188 | ||
| 189 | def on_mount(:default, _params, _session, socket) do | |
| 190 | 0 | unless Process.get(:plug_csp_nonce) do |
| 191 | 0 | Logger.debug(""" |
| 192 | LiveView session was misconfigured. | |
| 193 | ||
| 194 | 1) Ensure the `put_content_security_policy` plug is in your router pipeline: | |
| 195 | ||
| 196 | plug :put_content_security_policy | |
| 197 | ||
| 198 | 2) Define the CSRF meta tag inside the `<head>` tag in your layout: | |
| 199 | ||
| 200 | <meta name="csp-nonce" content={MyAppWeb.CSP.get_csp_nonce()} /> | |
| 201 | ||
| 202 | 3) Pass it forward in your app.js: | |
| 203 | ||
| 204 | let csrfToken = document.querySelector("meta[name='csp-nonce']").getAttribute("content"); | |
| 205 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csp_nonce: cspNonce}}); | |
| 206 | """) | |
| 207 | end | |
| 208 | ||
| 209 | {:cont, socket} | |
| 210 | end | |
| 211 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ActivityTypeLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | import LiveToast | |
| 5 | alias Klepsidra.TimeTracking | |
| 6 | ||
| 7 | @impl true | |
| 8 | def render(assigns) do | |
| 9 | 7 | ~H""" |
| 10 | <div> | |
| 11 | 3 | <.header> |
| 12 | 3 | <%= @title %> |
| 13 | </.header> | |
| 14 | ||
| 15 | 7 | <.simple_form |
| 16 | 7 | for={@form} |
| 17 | id="activity_type-form" | |
| 18 | 7 | phx-target={@myself} |
| 19 | phx-change="validate" | |
| 20 | phx-submit="save" | |
| 21 | > | |
| 22 | 7 | <.input field={@form[:name]} type="text" label="Activity type" /> |
| 23 | ||
| 24 | 7 | <.input field={@form[:billing_rate]} type="number" label="Billing rate" min="0" step="0.01" /> |
| 25 | ||
| 26 | 7 | <.input field={@form[:active]} type="checkbox" label="Active" /> |
| 27 | 7 | <:actions> |
| 28 | 7 | <.button phx-disable-with="Saving...">Save</.button> |
| 29 | </:actions> | |
| 30 | </.simple_form> | |
| 31 | </div> | |
| 32 | """ | |
| 33 | end | |
| 34 | ||
| 35 | @impl true | |
| 36 | 3 | def update(%{activity_type: activity_type} = assigns, socket) do |
| 37 | 3 | changeset = TimeTracking.change_activity_type(activity_type) |
| 38 | ||
| 39 | {:ok, | |
| 40 | socket | |
| 41 | |> assign(assigns) | |
| 42 | |> assign_form(changeset)} | |
| 43 | end | |
| 44 | ||
| 45 | @impl true | |
| 46 | 3 | def handle_event("validate", %{"activity_type" => activity_type_params}, socket) do |
| 47 | 3 | changeset = |
| 48 | 3 | socket.assigns.activity_type |
| 49 | |> TimeTracking.change_activity_type(activity_type_params) | |
| 50 | |> Map.put(:action, :validate) | |
| 51 | ||
| 52 | {:noreply, assign_form(socket, changeset)} | |
| 53 | end | |
| 54 | ||
| 55 | def handle_event("save", %{"activity_type" => activity_type_params}, socket) do | |
| 56 | 3 | save_activity_type(socket, socket.assigns.action, activity_type_params) |
| 57 | end | |
| 58 | ||
| 59 | 2 | defp save_activity_type(socket, :edit, activity_type_params) do |
| 60 | 2 | case TimeTracking.update_activity_type(socket.assigns.activity_type, activity_type_params) do |
| 61 | {:ok, activity_type} -> | |
| 62 | 2 | notify_parent({:saved, activity_type}) |
| 63 | ||
| 64 | {:noreply, | |
| 65 | socket | |
| 66 | |> put_toast(:info, "Activity type updated successfully") | |
| 67 | 2 | |> push_patch(to: socket.assigns.patch)} |
| 68 | ||
| 69 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 70 | {:noreply, assign_form(socket, changeset)} | |
| 71 | end | |
| 72 | end | |
| 73 | ||
| 74 | 0 | defp save_activity_type(socket, :new, activity_type_params) do |
| 75 | 1 | case TimeTracking.create_activity_type(activity_type_params) do |
| 76 | {:ok, activity_type} -> | |
| 77 | 0 | notify_parent({:saved, activity_type}) |
| 78 | ||
| 79 | {:noreply, | |
| 80 | socket | |
| 81 | |> put_toast(:info, "Activity type created successfully") | |
| 82 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 83 | ||
| 84 | 1 | {:error, %Ecto.Changeset{} = changeset} -> |
| 85 | {:noreply, assign_form(socket, changeset)} | |
| 86 | end | |
| 87 | end | |
| 88 | ||
| 89 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 90 | 7 | assign(socket, :form, to_form(changeset)) |
| 91 | end | |
| 92 | ||
| 93 | 2 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 94 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ActivityTypeLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | import LiveToast | |
| 5 | ||
| 6 | alias Klepsidra.TimeTracking | |
| 7 | alias Klepsidra.TimeTracking.ActivityType | |
| 8 | ||
| 9 | @impl true | |
| 10 | 8 | def mount(_params, _session, socket) do |
| 11 | {:ok, stream(socket, :activity_types, TimeTracking.list_activity_types())} | |
| 12 | end | |
| 13 | ||
| 14 | @impl true | |
| 15 | 11 | def handle_params(params, _url, socket) do |
| 16 | 11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 17 | end | |
| 18 | ||
| 19 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 20 | socket | |
| 21 | |> assign(:page_title, "Edit activity type") | |
| 22 | 1 | |> assign(:activity_type, TimeTracking.get_activity_type!(id)) |
| 23 | end | |
| 24 | ||
| 25 | defp apply_action(socket, :new, _params) do | |
| 26 | socket | |
| 27 | |> assign(:page_title, "New activity type") | |
| 28 | 1 | |> assign(:activity_type, %ActivityType{}) |
| 29 | end | |
| 30 | ||
| 31 | defp apply_action(socket, :index, _params) do | |
| 32 | socket | |
| 33 | |> assign(:page_title, "Activity types") | |
| 34 | 9 | |> assign(:activity_type, nil) |
| 35 | end | |
| 36 | ||
| 37 | @impl true | |
| 38 | 1 | def handle_info({KlepsidraWeb.ActivityTypeLive.FormComponent, {:saved, activity_type}}, socket) do |
| 39 | {:noreply, stream_insert(socket, :activity_types, activity_type)} | |
| 40 | end | |
| 41 | ||
| 42 | @impl true | |
| 43 | 1 | def handle_event("delete", %{"id" => id}, socket) do |
| 44 | 1 | activity_type = TimeTracking.get_activity_type!(id) |
| 45 | 1 | {:ok, _} = TimeTracking.delete_activity_type(activity_type) |
| 46 | ||
| 47 | {:noreply, handle_deleted_activity_type(socket, activity_type, :activity_types)} | |
| 48 | end | |
| 49 | ||
| 50 | defp handle_deleted_activity_type(socket, activity_type, source_stream) do | |
| 51 | socket | |
| 52 | |> stream_delete(source_stream, activity_type) | |
| 53 | 1 | |> put_toast(:info, "Activity type deleted successfully") |
| 54 | end | |
| 55 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ActivityTypeLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.TimeTracking | |
| 6 | ||
| 7 | @impl true | |
| 8 | 4 | def mount(_params, _session, socket) do |
| 9 | {:ok, socket} | |
| 10 | end | |
| 11 | ||
| 12 | @impl true | |
| 13 | 6 | def handle_params(%{"id" => id}, _, socket) do |
| 14 | {:noreply, | |
| 15 | socket | |
| 16 | 6 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 17 | |> assign(:activity_type, TimeTracking.get_activity_type!(id))} | |
| 18 | end | |
| 19 | ||
| 20 | 5 | defp page_title(:show), do: "Show Activity type" |
| 21 | 1 | defp page_title(:edit), do: "Edit Activity type" |
| 22 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.BusinessPartnerLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | import LiveToast | |
| 5 | alias Klepsidra.BusinessPartners | |
| 6 | ||
| 7 | @impl true | |
| 8 | def render(assigns) do | |
| 9 | 7 | ~H""" |
| 10 | <div> | |
| 11 | 3 | <.header> |
| 12 | 3 | <%= @title %> |
| 13 | </.header> | |
| 14 | ||
| 15 | 7 | <.simple_form |
| 16 | 7 | for={@form} |
| 17 | id="business_partner-form" | |
| 18 | 7 | phx-target={@myself} |
| 19 | phx-change="validate" | |
| 20 | phx-submit="save" | |
| 21 | > | |
| 22 | 7 | <.input field={@form[:name]} type="text" label="Name" /> |
| 23 | 7 | <.input field={@form[:description]} type="textarea" label="Description" /> |
| 24 | 7 | <:actions> |
| 25 | 7 | <.button phx-disable-with="Saving...">Save</.button> |
| 26 | </:actions> | |
| 27 | </.simple_form> | |
| 28 | </div> | |
| 29 | """ | |
| 30 | end | |
| 31 | ||
| 32 | @impl true | |
| 33 | 3 | def update(%{business_partner: business_partner} = assigns, socket) do |
| 34 | 3 | changeset = BusinessPartners.change_business_partner(business_partner) |
| 35 | ||
| 36 | {:ok, | |
| 37 | socket | |
| 38 | |> assign(assigns) | |
| 39 | |> assign_form(changeset)} | |
| 40 | end | |
| 41 | ||
| 42 | @impl true | |
| 43 | 3 | def handle_event("validate", %{"business_partner" => business_partner_params}, socket) do |
| 44 | 3 | changeset = |
| 45 | 3 | socket.assigns.business_partner |
| 46 | |> BusinessPartners.change_business_partner(business_partner_params) | |
| 47 | |> Map.put(:action, :validate) | |
| 48 | ||
| 49 | {:noreply, assign_form(socket, changeset)} | |
| 50 | end | |
| 51 | ||
| 52 | def handle_event("save", %{"business_partner" => business_partner_params}, socket) do | |
| 53 | 3 | business_partner_params = Map.merge(business_partner_params, %{"customer" => "true"}) |
| 54 | ||
| 55 | 3 | save_business_partner(socket, socket.assigns.action, business_partner_params) |
| 56 | end | |
| 57 | ||
| 58 | 2 | defp save_business_partner(socket, :edit, business_partner_params) do |
| 59 | 2 | business_partner_type = |
| 60 | 2 | if socket.assigns.business_partner_type == :customer, do: "Customer", else: "Supplier" |
| 61 | ||
| 62 | 2 | case BusinessPartners.update_business_partner( |
| 63 | 2 | socket.assigns.business_partner, |
| 64 | business_partner_params | |
| 65 | ) do | |
| 66 | {:ok, business_partner} -> | |
| 67 | 2 | notify_parent({:saved, business_partner}) |
| 68 | ||
| 69 | {:noreply, | |
| 70 | socket | |
| 71 | 2 | |> put_toast(:info, "#{business_partner_type} updated successfully") |
| 72 | 2 | |> push_patch(to: socket.assigns.patch)} |
| 73 | ||
| 74 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 75 | {:noreply, assign_form(socket, changeset)} | |
| 76 | end | |
| 77 | end | |
| 78 | ||
| 79 | 0 | defp save_business_partner(socket, :new, business_partner_params) do |
| 80 | 1 | business_partner_type = |
| 81 | 1 | if socket.assigns.business_partner_type == :customer, do: "Customer", else: "Supplier" |
| 82 | ||
| 83 | 1 | case BusinessPartners.create_business_partner(business_partner_params) do |
| 84 | {:ok, business_partner} -> | |
| 85 | 0 | notify_parent({:saved, business_partner}) |
| 86 | ||
| 87 | {:noreply, | |
| 88 | socket | |
| 89 | 0 | |> put_flash(:info, "#{business_partner_type} created successfully") |
| 90 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 91 | ||
| 92 | 1 | {:error, %Ecto.Changeset{} = changeset} -> |
| 93 | {:noreply, assign_form(socket, changeset)} | |
| 94 | end | |
| 95 | end | |
| 96 | ||
| 97 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 98 | 7 | assign(socket, :form, to_form(changeset)) |
| 99 | end | |
| 100 | ||
| 101 | 2 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 102 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.BusinessPartnerLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | import LiveToast | |
| 5 | ||
| 6 | alias Klepsidra.BusinessPartners | |
| 7 | alias Klepsidra.BusinessPartners.BusinessPartner | |
| 8 | ||
| 9 | @impl true | |
| 10 | 8 | def mount(_params, _session, socket) do |
| 11 | 8 | socket = |
| 12 | socket | |
| 13 | |> assign(:business_partner_type, :customer) | |
| 14 | |> stream(:business_partners, BusinessPartners.list_business_partners()) | |
| 15 | ||
| 16 | {:ok, socket} | |
| 17 | end | |
| 18 | ||
| 19 | @impl true | |
| 20 | 11 | def handle_params(params, _url, socket) do |
| 21 | 11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 22 | end | |
| 23 | ||
| 24 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 25 | socket | |
| 26 | |> assign(:page_title, "Edit customer details") | |
| 27 | 1 | |> assign(:business_partner, BusinessPartners.get_business_partner!(id)) |
| 28 | end | |
| 29 | ||
| 30 | defp apply_action(socket, :new, _params) do | |
| 31 | socket | |
| 32 | |> assign(:page_title, "New customer") | |
| 33 | 1 | |> assign(:business_partner, %BusinessPartner{}) |
| 34 | end | |
| 35 | ||
| 36 | defp apply_action(socket, :index, _params) do | |
| 37 | socket | |
| 38 | |> assign(:page_title, "Customers") | |
| 39 | 9 | |> assign(:business_partner, nil) |
| 40 | end | |
| 41 | ||
| 42 | @impl true | |
| 43 | 1 | def handle_info( |
| 44 | {KlepsidraWeb.BusinessPartnerLive.FormComponent, {:saved, business_partner}}, | |
| 45 | socket | |
| 46 | ) do | |
| 47 | {:noreply, stream_insert(socket, :business_partners, business_partner)} | |
| 48 | end | |
| 49 | ||
| 50 | @impl true | |
| 51 | 1 | def handle_event("delete", %{"id" => id}, socket) do |
| 52 | 1 | business_partner = BusinessPartners.get_business_partner!(id) |
| 53 | 1 | {:ok, _} = BusinessPartners.delete_business_partner(business_partner) |
| 54 | ||
| 55 | {:noreply, handle_deleted_customer(socket, business_partner, :business_partners)} | |
| 56 | end | |
| 57 | ||
| 58 | defp handle_deleted_customer(socket, customer, source_stream) do | |
| 59 | socket | |
| 60 | |> stream_delete(source_stream, customer) | |
| 61 | 1 | |> put_toast(:info, "Customer deleted successfully") |
| 62 | end | |
| 63 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.BusinessPartnerLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.BusinessPartners | |
| 6 | ||
| 7 | @impl true | |
| 8 | 4 | def mount(_params, _session, socket) do |
| 9 | {:ok, assign(socket, :business_partner_type, :customer)} | |
| 10 | end | |
| 11 | ||
| 12 | @impl true | |
| 13 | 6 | def handle_params(%{"id" => id}, _, socket) do |
| 14 | {:noreply, | |
| 15 | socket | |
| 16 | 6 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 17 | |> assign(:business_partner, BusinessPartners.get_business_partner!(id))} | |
| 18 | end | |
| 19 | ||
| 20 | 5 | defp page_title(:show), do: "Show customer" |
| 21 | 1 | defp page_title(:edit), do: "Edit customer" |
| 22 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.JournalEntryLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | ||
| 5 | alias Klepsidra.Journals | |
| 6 | alias Klepsidra.Journals.JournalEntryTypes | |
| 7 | alias Klepsidra.Locations.City | |
| 8 | alias Klepsidra.Categorisation | |
| 9 | alias Klepsidra.Categorisation.Tag | |
| 10 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 11 | alias Klepsidra.DynamicCSS | |
| 12 | ||
| 13 | @tag_search_live_component_id "journal_entry_ls_tag_search_live_select_component" | |
| 14 | ||
| 15 | @impl true | |
| 16 | def render(assigns) do | |
| 17 | 0 | ~H""" |
| 18 | <div> | |
| 19 | 0 | <.header> |
| 20 | 0 | <%= @title %> |
| 21 | 0 | <:subtitle :if={@action == :new}>What did you do today?</:subtitle> |
| 22 | </.header> | |
| 23 | ||
| 24 | 0 | <.simple_form |
| 25 | 0 | for={@form} |
| 26 | id="journal_entry-form" | |
| 27 | 0 | phx-target={@myself} |
| 28 | phx-change="validate" | |
| 29 | phx-window-keyup="key_up" | |
| 30 | phx-submit="save" | |
| 31 | > | |
| 32 | 0 | <.input |
| 33 | 0 | :if={@action == :new} |
| 34 | 0 | field={@form[:journal_for]} |
| 35 | type="date" | |
| 36 | label="Journal for" | |
| 37 | 0 | value={@datestamp} |
| 38 | /> | |
| 39 | 0 | <.input :if={@action == :edit} field={@form[:journal_for]} type="date" label="Journal for" /> |
| 40 | 0 | <.input field={@form[:entry_type_id]} type="select" label="Entry type" options={@entry_types} /> |
| 41 | 0 | <.input |
| 42 | 0 | field={@form[:entry_text_markdown]} |
| 43 | type="textarea" | |
| 44 | label="Journal entry" | |
| 45 | phx-debounce="1500" | |
| 46 | /> | |
| 47 | 0 | <.input |
| 48 | 0 | field={@form[:highlights]} |
| 49 | type="text" | |
| 50 | label="Key takeaways or highlights" | |
| 51 | placeholder="Summary of key points" | |
| 52 | /> | |
| 53 | ||
| 54 | 0 | <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}> |
| 55 | 0 | <div |
| 56 | id="tag-selector__live-select" | |
| 57 | phx-mounted={JS.add_class("hidden", to: "#journal_entry_ls_tag_search_text_input")} | |
| 58 | > | |
| 59 | 0 | <.live_select |
| 60 | 0 | field={@form[:ls_tag_search]} |
| 61 | mode={:tags} | |
| 62 | label="" | |
| 63 | options={[]} | |
| 64 | placeholder="Add tag" | |
| 65 | debounce={80} | |
| 66 | clear_tag_button_class="cursor-pointer px-1 rounded-r-md" | |
| 67 | dropdown_extra_class="bg-white max-h-48 overflow-y-scroll" | |
| 68 | tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold" | |
| 69 | tags_container_class="flex flex-wrap gap-2" | |
| 70 | container_extra_class="rounded border border-none" | |
| 71 | update_min_len={1} | |
| 72 | user_defined_options="true" | |
| 73 | 0 | value={@selected_tags} |
| 74 | phx-blur="ls_tag_search_blur" | |
| 75 | 0 | phx-target={@myself} |
| 76 | > | |
| 77 | 0 | <:option :let={option}> |
| 78 | 0 | <div class="flex" title={option.description}> |
| 79 | 0 | <%= option.label %> |
| 80 | </div> | |
| 81 | </:option> | |
| 82 | 0 | <:tag :let={option}> |
| 83 | 0 | <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}> |
| 84 | 0 | <.link navigate={~p"/tags/#{option.value}"}> |
| 85 | 0 | <%= option.label %> |
| 86 | </.link> | |
| 87 | </div> | |
| 88 | </:tag> | |
| 89 | </.live_select> | |
| 90 | </div> | |
| 91 | ||
| 92 | <div | |
| 93 | id="tag-selector__colour-select" | |
| 94 | class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0" | |
| 95 | > | |
| 96 | 0 | <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} /> |
| 97 | </div> | |
| 98 | ||
| 99 | 0 | <.button |
| 100 | id="tag-selector__add-button" | |
| 101 | class="add-tag-button flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md" | |
| 102 | type="button" | |
| 103 | phx-click={enable_tag_selector()} | |
| 104 | > | |
| 105 | Add tag + | |
| 106 | </.button> | |
| 107 | </div> | |
| 108 | ||
| 109 | 0 | <.input field={@form[:mood]} type="text" label="How would you describe your mood?" /> |
| 110 | 0 | <.live_select |
| 111 | 0 | field={@form[:location_id]} |
| 112 | mode={:single} | |
| 113 | label="Location" | |
| 114 | options={[]} | |
| 115 | placeholder="Where are you?" | |
| 116 | debounce={200} | |
| 117 | dropdown_extra_class="bg-white max-h-48 overflow-y-scroll" | |
| 118 | update_min_len={2} | |
| 119 | value_mapper={&value_mapper/1} | |
| 120 | phx-focus="location_focus" | |
| 121 | phx-blur="location_blur" | |
| 122 | 0 | phx-target={@myself} |
| 123 | > | |
| 124 | 0 | <:option :let={option}> |
| 125 | <div class="flex"> | |
| 126 | 0 | <%= option.label %> |
| 127 | </div> | |
| 128 | </:option> | |
| 129 | </.live_select> | |
| 130 | ||
| 131 | 0 | <.input field={@form[:is_private]} type="checkbox" label="Private entry?" /> |
| 132 | 0 | <:actions> |
| 133 | 0 | <.button phx-disable-with="Saving...">Save journal entry</.button> |
| 134 | </:actions> | |
| 135 | </.simple_form> | |
| 136 | </div> | |
| 137 | """ | |
| 138 | end | |
| 139 | ||
| 140 | @impl true | |
| 141 | 0 | def update(%{journal_entry: journal_entry} = assigns, socket) do |
| 142 | 0 | journal_entry = journal_entry |> Klepsidra.Repo.preload(:tags) |
| 143 | ||
| 144 | 0 | socket = |
| 145 | socket | |
| 146 | |> TagUtilities.generate_tag_options( | |
| 147 | [], | |
| 148 | 0 | Enum.map(journal_entry.tags, fn tag -> tag.id end), |
| 149 | @tag_search_live_component_id | |
| 150 | ) | |
| 151 | |> Phx.Live.Head.push( | |
| 152 | "style[id*=dynamic-style-block]", | |
| 153 | :dynamic, | |
| 154 | "style_declarations", | |
| 155 | 0 | DynamicCSS.generate_tag_styles(journal_entry.tags) |
| 156 | ) | |
| 157 | |> assign(assigns) | |
| 158 | |> assign_new(:form, fn -> | |
| 159 | 0 | to_form(Journals.change_journal_entry(journal_entry)) |
| 160 | end) | |
| 161 | |> assign(new_tag_colour: {"#94a3b8", "#fff"}) | |
| 162 | |> assign_entry_type() | |
| 163 | ||
| 164 | {:ok, socket} | |
| 165 | end | |
| 166 | ||
| 167 | @impl true | |
| 168 | 0 | def handle_event( |
| 169 | "validate", | |
| 170 | %{ | |
| 171 | "_target" => ["journal_entry", "ls_tag_search"], | |
| 172 | "journal_entry" => %{"ls_tag_search" => tags_applied} | |
| 173 | }, | |
| 174 | socket | |
| 175 | ) do | |
| 176 | 0 | Tag.handle_tag_list_changes( |
| 177 | 0 | socket.assigns.selected_tag_queue, |
| 178 | tags_applied, | |
| 179 | 0 | socket.assigns.journal_entry.id, |
| 180 | &Categorisation.add_journal_entry_tag(&1, &2), | |
| 181 | &Categorisation.delete_journal_entry_tag(&1, &2) | |
| 182 | ) | |
| 183 | ||
| 184 | 0 | socket = |
| 185 | TagUtilities.generate_tag_options( | |
| 186 | socket, | |
| 187 | 0 | socket.assigns.selected_tag_queue, |
| 188 | tags_applied, | |
| 189 | @tag_search_live_component_id, | |
| 190 | 0 | parent_tag_select_id: socket.assigns.parent_tag_select_id |
| 191 | ) | |
| 192 | |> Phx.Live.Head.push( | |
| 193 | "style[id*=dynamic-style-block]", | |
| 194 | :dynamic, | |
| 195 | "style_declarations", | |
| 196 | DynamicCSS.generate_tag_styles(tags_applied) | |
| 197 | ) | |
| 198 | |> assign( | |
| 199 | tag_search_phrase: nil, | |
| 200 | possible_free_tag_entered: false | |
| 201 | ) | |
| 202 | ||
| 203 | {:noreply, socket} | |
| 204 | end | |
| 205 | ||
| 206 | @doc """ | |
| 207 | Validate event which fires only once the last of the tags has been cleared | |
| 208 | from a `live_select` component. | |
| 209 | """ | |
| 210 | 0 | def handle_event( |
| 211 | "validate", | |
| 212 | %{ | |
| 213 | "_target" => ["journal_entry", "ls_tag_search_empty_selection"], | |
| 214 | "journal_entry" => %{"ls_tag_search_empty_selection" => ""} | |
| 215 | }, | |
| 216 | socket | |
| 217 | ) do | |
| 218 | 0 | Tag.handle_tag_list_changes( |
| 219 | 0 | socket.assigns.selected_tag_queue, |
| 220 | [], | |
| 221 | 0 | socket.assigns.journal_entry.id, |
| 222 | &Categorisation.add_journal_entry_tag(&1, &2), | |
| 223 | &Categorisation.delete_journal_entry_tag(&1, &2) | |
| 224 | ) | |
| 225 | ||
| 226 | 0 | socket.assigns.parent_tag_select_id && |
| 227 | 0 | send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: []) |
| 228 | ||
| 229 | 0 | socket = |
| 230 | socket | |
| 231 | |> assign( | |
| 232 | tag_search_phrase: nil, | |
| 233 | possible_free_tag_entered: false | |
| 234 | ) | |
| 235 | ||
| 236 | {:noreply, socket} | |
| 237 | end | |
| 238 | ||
| 239 | 0 | def handle_event( |
| 240 | "validate", | |
| 241 | %{ | |
| 242 | "_target" => ["journal_entry", "bg_colour"], | |
| 243 | "journal_entry" => %{ | |
| 244 | "bg_colour" => bg_colour | |
| 245 | } | |
| 246 | }, | |
| 247 | socket | |
| 248 | ) do | |
| 249 | 0 | fg_colour = |
| 250 | case ColorContrast.calc_contrast(bg_colour) do | |
| 251 | 0 | {:ok, fg_colour} -> fg_colour |
| 252 | 0 | {:error, _} -> "#fff" |
| 253 | end | |
| 254 | ||
| 255 | 0 | socket = |
| 256 | socket | |
| 257 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 258 | ||
| 259 | {:noreply, socket} | |
| 260 | end | |
| 261 | ||
| 262 | 0 | def handle_event("validate", %{"journal_entry" => journal_entry_params}, socket) do |
| 263 | 0 | changeset = |
| 264 | 0 | socket.assigns.journal_entry |
| 265 | |> Journals.change_journal_entry(journal_entry_params) | |
| 266 | ||
| 267 | {:noreply, assign(socket, form: to_form(changeset, action: :validate))} | |
| 268 | end | |
| 269 | ||
| 270 | def handle_event("save", %{"journal_entry" => journal_entry_params}, socket) do | |
| 271 | 0 | save_journal_entry(socket, socket.assigns.action, journal_entry_params) |
| 272 | end | |
| 273 | ||
| 274 | @impl true | |
| 275 | 0 | def handle_event( |
| 276 | "live_select_change", | |
| 277 | %{ | |
| 278 | "field" => "journal_entry_ls_tag_search", | |
| 279 | "id" => live_select_id, | |
| 280 | "text" => tag_search_phrase | |
| 281 | }, | |
| 282 | socket | |
| 283 | ) do | |
| 284 | 0 | tag_search_results = |
| 285 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 286 | |> TagUtilities.tag_options_for_live_select() | |
| 287 | ||
| 288 | 0 | send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results) |
| 289 | ||
| 290 | 0 | socket = |
| 291 | socket | |
| 292 | |> assign( | |
| 293 | tag_search_phrase: tag_search_phrase, | |
| 294 | possible_free_tag_entered: true | |
| 295 | ) | |
| 296 | ||
| 297 | {:noreply, socket} | |
| 298 | end | |
| 299 | ||
| 300 | 0 | def handle_event( |
| 301 | "ls_tag_search_blur", | |
| 302 | %{"id" => @tag_search_live_component_id}, | |
| 303 | socket | |
| 304 | ) do | |
| 305 | 0 | socket = |
| 306 | socket | |
| 307 | |> assign( | |
| 308 | tag_search_phrase: nil, | |
| 309 | possible_free_tag_entered: false | |
| 310 | ) | |
| 311 | ||
| 312 | {:noreply, socket} | |
| 313 | end | |
| 314 | ||
| 315 | 0 | def handle_event( |
| 316 | "key_up", | |
| 317 | %{"key" => "Enter"}, | |
| 318 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 319 | socket | |
| 320 | ) do | |
| 321 | 0 | socket = |
| 322 | TagUtilities.handle_free_tagging( | |
| 323 | socket, | |
| 324 | tag_search_phrase, | |
| 325 | String.length(tag_search_phrase), | |
| 326 | @tag_search_live_component_id, | |
| 327 | 0 | socket.assigns.new_tag_colour |
| 328 | ) | |
| 329 | ||
| 330 | {:noreply, socket} | |
| 331 | end | |
| 332 | ||
| 333 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 334 | ||
| 335 | 0 | def handle_event( |
| 336 | "live_select_change", | |
| 337 | %{ | |
| 338 | "field" => "journal_entry_location_id", | |
| 339 | "id" => live_select_id, | |
| 340 | "text" => text | |
| 341 | }, | |
| 342 | socket | |
| 343 | ) do | |
| 344 | 0 | cities = Klepsidra.Locations.city_search(text) |
| 345 | ||
| 346 | 0 | send_update(LiveSelect.Component, id: live_select_id, options: cities) |
| 347 | ||
| 348 | {:noreply, socket} | |
| 349 | end | |
| 350 | ||
| 351 | 0 | def handle_event("focus", _params, socket) do |
| 352 | {:noreply, socket} | |
| 353 | end | |
| 354 | ||
| 355 | 0 | def handle_event("clear", %{"id" => id}, socket) do |
| 356 | 0 | send_update(LiveSelect.Component, options: [], id: id) |
| 357 | ||
| 358 | {:noreply, socket} | |
| 359 | end | |
| 360 | ||
| 361 | 0 | def handle_event("location_focus", %{"id" => _id}, socket) do |
| 362 | {:noreply, socket} | |
| 363 | end | |
| 364 | ||
| 365 | 0 | def handle_event("location_blur", %{"id" => _id}, socket) do |
| 366 | {:noreply, socket} | |
| 367 | end | |
| 368 | ||
| 369 | 0 | defp save_journal_entry(socket, :edit, journal_entry_params) do |
| 370 | 0 | case Journals.update_journal_entry(socket.assigns.journal_entry, journal_entry_params) do |
| 371 | {:ok, journal_entry} -> | |
| 372 | 0 | journal_entry = |
| 373 | [journal_entry | []] | |
| 374 | |> Klepsidra.Journals.preload_journal_entry_type() | |
| 375 | |> List.first() | |
| 376 | ||
| 377 | 0 | notify_parent({:saved, journal_entry}) |
| 378 | ||
| 379 | {:noreply, | |
| 380 | socket | |
| 381 | |> put_flash(:info, "Journal entry updated successfully") | |
| 382 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 383 | ||
| 384 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 385 | {:noreply, assign(socket, form: to_form(changeset))} | |
| 386 | end | |
| 387 | end | |
| 388 | ||
| 389 | 0 | defp save_journal_entry(socket, :new, journal_entry_params) do |
| 390 | 0 | case Journals.create_journal_entry(journal_entry_params) do |
| 391 | {:ok, journal_entry} -> | |
| 392 | 0 | journal_entry = |
| 393 | [journal_entry | []] | |
| 394 | |> Klepsidra.Journals.preload_journal_entry_type() | |
| 395 | |> List.first() | |
| 396 | ||
| 397 | 0 | notify_parent({:saved, journal_entry}) |
| 398 | ||
| 399 | 0 | Tag.handle_tag_list_changes( |
| 400 | [], | |
| 401 | 0 | socket.assigns.selected_tag_queue, |
| 402 | 0 | journal_entry.id, |
| 403 | &Categorisation.add_journal_entry_tag(&1, &2), | |
| 404 | &Categorisation.delete_journal_entry_tag(&1, &2) | |
| 405 | ) | |
| 406 | ||
| 407 | {:noreply, | |
| 408 | socket | |
| 409 | |> put_flash(:info, "Journal entry logged successfully") | |
| 410 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 411 | ||
| 412 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 413 | {:noreply, assign(socket, form: to_form(changeset))} | |
| 414 | end | |
| 415 | end | |
| 416 | ||
| 417 | @spec assign_entry_type(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() | |
| 418 | defp assign_entry_type(socket) do | |
| 419 | 0 | entry_types = JournalEntryTypes.populate_entry_types_list() |
| 420 | ||
| 421 | 0 | assign(socket, entry_types: entry_types) |
| 422 | end | |
| 423 | ||
| 424 | 0 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 425 | ||
| 426 | defp value_mapper(location_id) when is_bitstring(location_id) do | |
| 427 | 0 | City.city_option_for_select(location_id) |
| 428 | end | |
| 429 | ||
| 430 | 0 | defp value_mapper(value), do: value |
| 431 | ||
| 432 | defp enable_tag_selector() do | |
| 433 | JS.remove_class("hidden", to: "#journal_entry_ls_tag_search_text_input") | |
| 434 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select") | |
| 435 | |> JS.add_class("hidden", to: "#tag-selector__add-button") | |
| 436 | |> JS.add_class("gap-2", to: "#tag-selector") | |
| 437 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select") | |
| 438 | 0 | |> JS.focus(to: "#journal_entry_ls_tag_search_text_input") |
| 439 | end | |
| 440 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.JournalEntryLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Journals | |
| 6 | alias Klepsidra.Journals.JournalEntry | |
| 7 | import LiveToast | |
| 8 | ||
| 9 | @impl true | |
| 10 | 0 | def mount(_params, _session, socket) do |
| 11 | 0 | datestamp = Date.utc_today() |> Date.to_string() |
| 12 | ||
| 13 | 0 | journal_entries = |
| 14 | Journals.list_journal_entries() | |
| 15 | |> Journals.preload_journal_entry_type() | |
| 16 | ||
| 17 | {:ok, | |
| 18 | socket | |
| 19 | |> assign(:datestamp, datestamp) | |
| 20 | |> assign(:location_select_value, {"", ""}) | |
| 21 | |> stream(:journal_entries, journal_entries)} | |
| 22 | end | |
| 23 | ||
| 24 | @impl true | |
| 25 | 0 | def handle_params(params, _url, socket) do |
| 26 | 0 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 27 | end | |
| 28 | ||
| 29 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 30 | socket | |
| 31 | |> assign(:page_title, "Edit journal entry") | |
| 32 | 0 | |> assign(:journal_entry, Journals.get_journal_entry!(id)) |
| 33 | end | |
| 34 | ||
| 35 | defp apply_action(socket, :new, _params) do | |
| 36 | socket | |
| 37 | |> assign(:page_title, "New journal entry") | |
| 38 | 0 | |> assign(:journal_entry, %JournalEntry{}) |
| 39 | end | |
| 40 | ||
| 41 | defp apply_action(socket, :index, _params) do | |
| 42 | socket | |
| 43 | |> assign(:page_title, "Journal entries") | |
| 44 | 0 | |> assign(:journal_entry, nil) |
| 45 | end | |
| 46 | ||
| 47 | @impl true | |
| 48 | 0 | def handle_info({KlepsidraWeb.JournalEntryLive.FormComponent, {:saved, journal_entry}}, socket) do |
| 49 | {:noreply, stream_insert(socket, :journal_entries, journal_entry)} | |
| 50 | end | |
| 51 | ||
| 52 | @impl true | |
| 53 | 0 | def handle_event("delete", %{"id" => id}, socket) do |
| 54 | 0 | journal_entry = Journals.get_journal_entry!(id) |
| 55 | 0 | {:ok, _} = Journals.delete_journal_entry(journal_entry) |
| 56 | ||
| 57 | 0 | socket = |
| 58 | socket | |
| 59 | |> stream_delete(:journal_entries, journal_entry) | |
| 60 | |> put_toast(:info, "Journal entry deleted succesfully") | |
| 61 | ||
| 62 | {:noreply, socket} | |
| 63 | end | |
| 64 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.JournalEntryLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Journals | |
| 6 | alias Klepsidra.Journals.JournalEntry | |
| 7 | alias Klepsidra.Locations.City | |
| 8 | alias Klepsidra.Categorisation | |
| 9 | alias Klepsidra.Categorisation.Tag | |
| 10 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 11 | alias LiveSelect.Component | |
| 12 | alias Klepsidra.DynamicCSS | |
| 13 | ||
| 14 | defmodule TagSearch do | |
| 15 | @moduledoc """ | |
| 16 | The `TagSearch` module defines an embedded `tag_search` schema | |
| 17 | containing the tags for this journal_entry. | |
| 18 | """ | |
| 19 | use Ecto.Schema | |
| 20 | ||
| 21 | import Ecto.Changeset | |
| 22 | ||
| 23 | @type t :: %__MODULE__{ | |
| 24 | tag_search: Tag.t() | |
| 25 | } | |
| 26 | 0 | embedded_schema do |
| 27 | embeds_many(:tag_search, Tag, on_replace: :delete) | |
| 28 | field(:bg_colour, :string) | |
| 29 | end | |
| 30 | ||
| 31 | @doc false | |
| 32 | def changeset(schema \\ %__MODULE__{}, params) do | |
| 33 | cast(schema, params, []) | |
| 34 | 0 | |> cast_embed(:tag_search) |
| 35 | end | |
| 36 | end | |
| 37 | ||
| 38 | @tag_search_live_component_id "tag_form_tag_search_live_select_component" | |
| 39 | ||
| 40 | @impl true | |
| 41 | 0 | def mount(params, _session, socket) do |
| 42 | 0 | journal_entry_id = Map.get(params, "id") |
| 43 | ||
| 44 | 0 | journal_entry = Journals.get_journal_entry!(journal_entry_id) |> Klepsidra.Repo.preload(:tags) |
| 45 | ||
| 46 | 0 | socket = |
| 47 | socket | |
| 48 | |> TagUtilities.generate_tag_options( | |
| 49 | [], | |
| 50 | 0 | Enum.map(journal_entry.tags, fn tag -> tag.id end), |
| 51 | @tag_search_live_component_id | |
| 52 | ) | |
| 53 | |> Phx.Live.Head.push( | |
| 54 | "style[id*=dynamic-style-block]", | |
| 55 | :dynamic, | |
| 56 | "style_declarations", | |
| 57 | 0 | DynamicCSS.generate_tag_styles(journal_entry.tags) |
| 58 | ) | |
| 59 | |> assign( | |
| 60 | live_select_form: to_form(TagSearch.changeset(%{}), as: "tag_form"), | |
| 61 | new_tag_colour: {"#94a3b8", "#fff"} | |
| 62 | ) | |
| 63 | ||
| 64 | {:ok, socket} | |
| 65 | end | |
| 66 | ||
| 67 | @impl true | |
| 68 | 0 | def handle_params(%{"id" => id}, _, socket) do |
| 69 | 0 | journal_entry = get_journal(id) |
| 70 | ||
| 71 | 0 | journal_entry_type = get_journal_entry_type(journal_entry.entry_type_id |> to_string()) |
| 72 | 0 | location_select_value = City.city_option_for_select(journal_entry.location_id) |
| 73 | ||
| 74 | {:noreply, | |
| 75 | socket | |
| 76 | 0 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 77 | |> assign(:journal_entry, journal_entry) | |
| 78 | |> assign(:journal_entry_type, journal_entry_type) | |
| 79 | 0 | |> assign(:location_formatted_name, location_select_value.label)} |
| 80 | end | |
| 81 | ||
| 82 | @impl true | |
| 83 | 0 | def handle_event( |
| 84 | "live_select_change", | |
| 85 | %{ | |
| 86 | "field" => "tag_form_tag_search", | |
| 87 | "id" => @tag_search_live_component_id, | |
| 88 | "text" => tag_search_phrase | |
| 89 | }, | |
| 90 | socket | |
| 91 | ) do | |
| 92 | 0 | tag_search_results = |
| 93 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 94 | |> TagUtilities.tag_options_for_live_select() | |
| 95 | ||
| 96 | 0 | send_update(Component, |
| 97 | id: @tag_search_live_component_id, | |
| 98 | options: tag_search_results | |
| 99 | ) | |
| 100 | ||
| 101 | 0 | socket = |
| 102 | socket | |
| 103 | |> assign( | |
| 104 | tag_search_phrase: tag_search_phrase, | |
| 105 | possible_free_tag_entered: true | |
| 106 | ) | |
| 107 | ||
| 108 | {:noreply, socket} | |
| 109 | end | |
| 110 | ||
| 111 | 0 | def handle_event( |
| 112 | "change", | |
| 113 | %{ | |
| 114 | "_target" => ["tag_form", "tag_search_empty_selection"], | |
| 115 | "tag_form" => %{ | |
| 116 | "tag_search_empty_selection" => "", | |
| 117 | "tag_search_text_input" => _tag_search_phrase | |
| 118 | } | |
| 119 | }, | |
| 120 | socket | |
| 121 | ) do | |
| 122 | 0 | Tag.handle_tag_list_changes( |
| 123 | 0 | socket.assigns.selected_tag_queue, |
| 124 | [], | |
| 125 | 0 | socket.assigns.journal_entry.id, |
| 126 | &Categorisation.add_journal_entry_tag(&1, &2), | |
| 127 | &Categorisation.delete_journal_entry_tag(&1, &2) | |
| 128 | ) | |
| 129 | ||
| 130 | 0 | socket = |
| 131 | socket | |
| 132 | |> assign( | |
| 133 | tag_search_phrase: nil, | |
| 134 | possible_free_tag_entered: false | |
| 135 | ) | |
| 136 | ||
| 137 | {:noreply, socket} | |
| 138 | end | |
| 139 | ||
| 140 | 0 | def handle_event( |
| 141 | "change", | |
| 142 | %{ | |
| 143 | "_target" => ["tag_form", "tag_search"], | |
| 144 | "tag_form" => %{ | |
| 145 | "tag_search" => selected_tags, | |
| 146 | "tag_search_text_input" => _tag_search_phrase | |
| 147 | } | |
| 148 | }, | |
| 149 | socket | |
| 150 | ) do | |
| 151 | 0 | Tag.handle_tag_list_changes( |
| 152 | 0 | socket.assigns.selected_tag_queue, |
| 153 | selected_tags, | |
| 154 | 0 | socket.assigns.journal_entry.id, |
| 155 | &Categorisation.add_journal_entry_tag(&1, &2), | |
| 156 | &Categorisation.delete_journal_entry_tag(&1, &2) | |
| 157 | ) | |
| 158 | ||
| 159 | 0 | socket = |
| 160 | TagUtilities.generate_tag_options( | |
| 161 | socket, | |
| 162 | 0 | socket.assigns.selected_tag_queue, |
| 163 | selected_tags, | |
| 164 | @tag_search_live_component_id | |
| 165 | ) | |
| 166 | |> Phx.Live.Head.push( | |
| 167 | "style[id*=dynamic-style-block]", | |
| 168 | :dynamic, | |
| 169 | "style_declarations", | |
| 170 | DynamicCSS.generate_tag_styles(selected_tags) | |
| 171 | ) | |
| 172 | |> assign( | |
| 173 | tag_search_phrase: nil, | |
| 174 | possible_free_tag_entered: false | |
| 175 | ) | |
| 176 | ||
| 177 | {:noreply, socket} | |
| 178 | end | |
| 179 | ||
| 180 | 0 | def handle_event( |
| 181 | "change", | |
| 182 | %{ | |
| 183 | "_target" => ["tag_form", "bg_colour"], | |
| 184 | "tag_form" => %{ | |
| 185 | "bg_colour" => bg_colour, | |
| 186 | "tag_search_text_input" => _tag_search_phrase | |
| 187 | } | |
| 188 | }, | |
| 189 | socket | |
| 190 | ) do | |
| 191 | 0 | fg_colour = |
| 192 | case ColorContrast.calc_contrast(bg_colour) do | |
| 193 | 0 | {:ok, fg_colour} -> fg_colour |
| 194 | 0 | {:error, _} -> "#fff" |
| 195 | end | |
| 196 | ||
| 197 | 0 | socket = |
| 198 | socket | |
| 199 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 200 | ||
| 201 | {:noreply, socket} | |
| 202 | end | |
| 203 | ||
| 204 | 0 | def handle_event( |
| 205 | "ls_tag_search_blur", | |
| 206 | %{"id" => @tag_search_live_component_id}, | |
| 207 | socket | |
| 208 | ) do | |
| 209 | 0 | socket = |
| 210 | socket | |
| 211 | |> assign( | |
| 212 | tag_search_phrase: nil, | |
| 213 | possible_free_tag_entered: false | |
| 214 | ) | |
| 215 | ||
| 216 | {:noreply, socket} | |
| 217 | end | |
| 218 | ||
| 219 | 0 | def handle_event( |
| 220 | "key_up", | |
| 221 | %{"key" => "Enter"}, | |
| 222 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 223 | socket | |
| 224 | ) do | |
| 225 | 0 | socket = |
| 226 | TagUtilities.handle_free_tagging( | |
| 227 | socket, | |
| 228 | tag_search_phrase, | |
| 229 | String.length(tag_search_phrase), | |
| 230 | @tag_search_live_component_id, | |
| 231 | 0 | socket.assigns.new_tag_colour |
| 232 | ) | |
| 233 | ||
| 234 | {:noreply, socket} | |
| 235 | end | |
| 236 | ||
| 237 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 238 | ||
| 239 | 0 | defp page_title(:show), do: "Show journal entry" |
| 240 | 0 | defp page_title(:edit), do: "Edit journal entry" |
| 241 | ||
| 242 | @spec get_journal(id :: Ecto.UUID.t()) :: JournalEntry.t() | |
| 243 | 0 | defp get_journal(id), do: Journals.get_journal_entry!(id) |
| 244 | ||
| 245 | @spec get_journal_entry_type(journal_entry_type_id :: Ecto.UUID.t()) :: | |
| 246 | String.t() | |
| 247 | defp get_journal_entry_type(journal_entry_type_id) when is_bitstring(journal_entry_type_id) do | |
| 248 | journal_entry_type_id | |
| 249 | |> Journals.get_journal_entry_types!() | |
| 250 | 0 | |> Map.get(:name) |
| 251 | end | |
| 252 | ||
| 253 | defp enable_tag_selector() do | |
| 254 | JS.remove_class("hidden", to: "#tag_form_tag_search_text_input") | |
| 255 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select--show") | |
| 256 | |> JS.add_class("hidden", to: "#tag-selector__add-button--show") | |
| 257 | |> JS.add_class("gap-2", to: "#tag-selector--show") | |
| 258 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select--show") | |
| 259 | 0 | |> JS.focus(to: "#tag_form_tag_search_text_input") |
| 260 | end | |
| 261 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.JournalEntryTypesLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | ||
| 5 | alias Klepsidra.Journals | |
| 6 | ||
| 7 | @impl true | |
| 8 | def render(assigns) do | |
| 9 | 2 | ~H""" |
| 10 | <div> | |
| 11 | 2 | <.header> |
| 12 | 2 | <%= @title %> |
| 13 | 2 | <:subtitle>Use this form to manage journal_entry_types records in your database.</:subtitle> |
| 14 | </.header> | |
| 15 | ||
| 16 | 2 | <.simple_form |
| 17 | 2 | for={@form} |
| 18 | id="journal_entry_types-form" | |
| 19 | 2 | phx-target={@myself} |
| 20 | phx-change="validate" | |
| 21 | phx-submit="save" | |
| 22 | > | |
| 23 | 2 | <.input field={@form[:name]} type="text" label="Name" /> |
| 24 | 2 | <.input field={@form[:description]} type="text" label="Description" /> |
| 25 | 2 | <:actions> |
| 26 | 2 | <.button phx-disable-with="Saving...">Save Journal entry types</.button> |
| 27 | </:actions> | |
| 28 | </.simple_form> | |
| 29 | </div> | |
| 30 | """ | |
| 31 | end | |
| 32 | ||
| 33 | @impl true | |
| 34 | 2 | def update(%{journal_entry_types: journal_entry_types} = assigns, socket) do |
| 35 | {:ok, | |
| 36 | socket | |
| 37 | |> assign(assigns) | |
| 38 | |> assign_new(:form, fn -> | |
| 39 | 2 | to_form(Journals.change_journal_entry_types(journal_entry_types)) |
| 40 | end)} | |
| 41 | end | |
| 42 | ||
| 43 | @impl true | |
| 44 | 0 | def handle_event("validate", %{"journal_entry_types" => journal_entry_types_params}, socket) do |
| 45 | 0 | changeset = |
| 46 | Journals.change_journal_entry_types( | |
| 47 | 0 | socket.assigns.journal_entry_types, |
| 48 | journal_entry_types_params | |
| 49 | ) | |
| 50 | ||
| 51 | {:noreply, assign(socket, form: to_form(changeset, action: :validate))} | |
| 52 | end | |
| 53 | ||
| 54 | def handle_event("save", %{"journal_entry_types" => journal_entry_types_params}, socket) do | |
| 55 | 0 | save_journal_entry_types(socket, socket.assigns.action, journal_entry_types_params) |
| 56 | end | |
| 57 | ||
| 58 | 0 | defp save_journal_entry_types(socket, :edit, journal_entry_types_params) do |
| 59 | 0 | case Journals.update_journal_entry_types( |
| 60 | 0 | socket.assigns.journal_entry_types, |
| 61 | journal_entry_types_params | |
| 62 | ) do | |
| 63 | {:ok, journal_entry_types} -> | |
| 64 | 0 | notify_parent({:saved, journal_entry_types}) |
| 65 | ||
| 66 | {:noreply, | |
| 67 | socket | |
| 68 | |> put_flash(:info, "Journal entry types updated successfully") | |
| 69 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 70 | ||
| 71 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 72 | {:noreply, assign(socket, form: to_form(changeset))} | |
| 73 | end | |
| 74 | end | |
| 75 | ||
| 76 | 0 | defp save_journal_entry_types(socket, :new, journal_entry_types_params) do |
| 77 | 0 | case Journals.create_journal_entry_types(journal_entry_types_params) do |
| 78 | {:ok, journal_entry_types} -> | |
| 79 | 0 | notify_parent({:saved, journal_entry_types}) |
| 80 | ||
| 81 | {:noreply, | |
| 82 | socket | |
| 83 | |> put_flash(:info, "Journal entry types created successfully") | |
| 84 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 85 | ||
| 86 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 87 | {:noreply, assign(socket, form: to_form(changeset))} | |
| 88 | end | |
| 89 | end | |
| 90 | ||
| 91 | 0 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 92 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.JournalEntryTypesLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Journals | |
| 6 | alias Klepsidra.Journals.JournalEntryTypes | |
| 7 | ||
| 8 | @impl true | |
| 9 | 4 | def mount(_params, _session, socket) do |
| 10 | {:ok, stream(socket, :journal_entry_types_collection, Journals.list_journal_entry_types())} | |
| 11 | end | |
| 12 | ||
| 13 | @impl true | |
| 14 | 5 | def handle_params(params, _url, socket) do |
| 15 | 5 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 16 | end | |
| 17 | ||
| 18 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 19 | socket | |
| 20 | |> assign(:page_title, "Edit Journal entry types") | |
| 21 | 0 | |> assign(:journal_entry_types, Journals.get_journal_entry_types!(id)) |
| 22 | end | |
| 23 | ||
| 24 | defp apply_action(socket, :new, _params) do | |
| 25 | socket | |
| 26 | |> assign(:page_title, "New Journal entry types") | |
| 27 | 1 | |> assign(:journal_entry_types, %JournalEntryTypes{}) |
| 28 | end | |
| 29 | ||
| 30 | defp apply_action(socket, :index, _params) do | |
| 31 | socket | |
| 32 | |> assign(:page_title, "Listing Journal entry types") | |
| 33 | 4 | |> assign(:journal_entry_types, nil) |
| 34 | end | |
| 35 | ||
| 36 | @impl true | |
| 37 | 0 | def handle_info( |
| 38 | {KlepsidraWeb.JournalEntryTypesLive.FormComponent, {:saved, journal_entry_types}}, | |
| 39 | socket | |
| 40 | ) do | |
| 41 | {:noreply, stream_insert(socket, :journal_entry_types_collection, journal_entry_types)} | |
| 42 | end | |
| 43 | ||
| 44 | @impl true | |
| 45 | 0 | def handle_event("delete", %{"id" => id}, socket) do |
| 46 | 0 | journal_entry_types = Journals.get_journal_entry_types!(id) |
| 47 | 0 | {:ok, _} = Journals.delete_journal_entry_types(journal_entry_types) |
| 48 | ||
| 49 | {:noreply, stream_delete(socket, :journal_entry_types_collection, journal_entry_types)} | |
| 50 | end | |
| 51 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.JournalEntryTypesLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Journals | |
| 6 | ||
| 7 | @impl true | |
| 8 | 4 | def mount(_params, _session, socket) do |
| 9 | {:ok, socket} | |
| 10 | end | |
| 11 | ||
| 12 | @impl true | |
| 13 | 5 | def handle_params(%{"id" => id}, _, socket) do |
| 14 | {:noreply, | |
| 15 | socket | |
| 16 | 5 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 17 | |> assign(:journal_entry_types, Journals.get_journal_entry_types!(id))} | |
| 18 | end | |
| 19 | ||
| 20 | 4 | defp page_title(:show), do: "Show Journal entry types" |
| 21 | 1 | defp page_title(:edit), do: "Edit Journal entry types" |
| 22 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.Live.NoteLive.NoteFormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | alias Klepsidra.TimeTracking | |
| 5 | alias Klepsidra.TimeTracking.Note | |
| 6 | ||
| 7 | @impl true | |
| 8 | def render(assigns) do | |
| 9 | 2 | ~H""" |
| 10 | <div> | |
| 11 | 2 | <.simple_form |
| 12 | 2 | for={@note_form} |
| 13 | id="note-form" | |
| 14 | phx-submit="save" | |
| 15 | phx-change="validate" | |
| 16 | 2 | phx-target={@myself} |
| 17 | 2 | phx-value-id={@note_form.data.id} |
| 18 | > | |
| 19 | 2 | <.input |
| 20 | 2 | field={@note_form[:note]} |
| 21 | type="textarea" | |
| 22 | placeholder="Type a new note here" | |
| 23 | autocomplete="off" | |
| 24 | /> | |
| 25 | 2 | <.button phx-disable-with="Saving note...">Save note</.button> |
| 26 | </.simple_form> | |
| 27 | </div> | |
| 28 | """ | |
| 29 | end | |
| 30 | ||
| 31 | @impl true | |
| 32 | 0 | def update(%{note: note} = assigns, socket) do |
| 33 | 0 | changeset = TimeTracking.change_note(note) |
| 34 | ||
| 35 | 0 | socket = |
| 36 | socket | |
| 37 | |> assign_form(changeset) | |
| 38 | |> assign(assigns) | |
| 39 | ||
| 40 | {:ok, socket} | |
| 41 | end | |
| 42 | ||
| 43 | @impl true | |
| 44 | 2 | def update(assigns, socket) do |
| 45 | 2 | changeset = TimeTracking.change_note(%Note{}) |
| 46 | ||
| 47 | {:ok, | |
| 48 | socket | |
| 49 | |> assign_form(changeset) | |
| 50 | |> assign(assigns)} | |
| 51 | end | |
| 52 | ||
| 53 | @impl true | |
| 54 | 0 | def handle_event("validate", %{"note" => note_params}, socket) do |
| 55 | 0 | changeset = |
| 56 | %Note{} | |
| 57 | |> TimeTracking.change_note(note_params) | |
| 58 | |> Map.put(:action, :validate) | |
| 59 | ||
| 60 | {:noreply, assign_form(socket, changeset)} | |
| 61 | end | |
| 62 | ||
| 63 | def handle_event("save", %{"note" => note_params}, socket) do | |
| 64 | 0 | note_params = Map.put(note_params, "timer_id", socket.assigns.timer_id) |
| 65 | ||
| 66 | 0 | save_note(socket, socket.assigns.action, note_params) |
| 67 | end | |
| 68 | ||
| 69 | 0 | defp save_note(socket, :edit_note, note_params) do |
| 70 | 0 | case TimeTracking.update_note(socket.assigns.note, note_params) do |
| 71 | {:ok, note} -> | |
| 72 | 0 | notify_parent({:updated_note, note}) |
| 73 | ||
| 74 | {:noreply, | |
| 75 | socket | |
| 76 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 77 | ||
| 78 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 79 | {:noreply, assign_form(socket, changeset)} | |
| 80 | end | |
| 81 | end | |
| 82 | ||
| 83 | 0 | defp save_note(socket, :new_note, note_params) do |
| 84 | 0 | case TimeTracking.create_note(note_params) do |
| 85 | {:ok, note} -> | |
| 86 | 0 | notify_parent({:saved_note, note}) |
| 87 | ||
| 88 | 0 | changeset = |
| 89 | TimeTracking.change_note(%Note{}) | |
| 90 | ||
| 91 | { | |
| 92 | :noreply, | |
| 93 | socket | |
| 94 | |> assign_form(changeset) | |
| 95 | 0 | |> push_patch(to: socket.assigns.patch) |
| 96 | } | |
| 97 | ||
| 98 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 99 | {:noreply, assign_form(socket, changeset)} | |
| 100 | end | |
| 101 | end | |
| 102 | ||
| 103 | 0 | defp save_note(socket, :new_embedded_note, note_params) do |
| 104 | 0 | case TimeTracking.create_note(note_params) do |
| 105 | {:ok, note} -> | |
| 106 | 0 | notify_parent({:saved_note, note}) |
| 107 | ||
| 108 | 0 | changeset = |
| 109 | TimeTracking.change_note(%Note{}) | |
| 110 | ||
| 111 | { | |
| 112 | :noreply, | |
| 113 | socket | |
| 114 | |> assign_form(changeset) | |
| 115 | } | |
| 116 | ||
| 117 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 118 | {:noreply, assign_form(socket, changeset)} | |
| 119 | end | |
| 120 | end | |
| 121 | ||
| 122 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 123 | 2 | assign(socket, :note_form, to_form(changeset)) |
| 124 | end | |
| 125 | ||
| 126 | 0 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 127 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ProjectLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | import LiveToast | |
| 5 | alias Klepsidra.Projects | |
| 6 | alias Klepsidra.Categorisation | |
| 7 | alias Klepsidra.Categorisation.Tag | |
| 8 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 9 | alias Klepsidra.DynamicCSS | |
| 10 | ||
| 11 | @tag_search_live_component_id "project_ls_tag_search_live_select_component" | |
| 12 | ||
| 13 | @impl true | |
| 14 | def render(assigns) do | |
| 15 | 7 | ~H""" |
| 16 | <div> | |
| 17 | 3 | <.header> |
| 18 | 3 | <%= @title %> |
| 19 | </.header> | |
| 20 | ||
| 21 | 7 | <.simple_form |
| 22 | 7 | for={@form} |
| 23 | id="project-form" | |
| 24 | 7 | phx-target={@myself} |
| 25 | phx-change="validate" | |
| 26 | phx-window-keyup="key_up" | |
| 27 | phx-submit="save" | |
| 28 | > | |
| 29 | 7 | <.input field={@form[:name]} type="text" label="Name" /> |
| 30 | 7 | <.input field={@form[:description]} type="textarea" label="Description" /> |
| 31 | 7 | <.input :if={@action == :edit} field={@form[:active]} type="checkbox" label="Active?" /> |
| 32 | ||
| 33 | 3 | <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}> |
| 34 | 3 | <div |
| 35 | id="tag-selector__live-select" | |
| 36 | phx-mounted={JS.add_class("hidden", to: "#project_ls_tag_search_text_input")} | |
| 37 | > | |
| 38 | 7 | <.live_select |
| 39 | 7 | field={@form[:ls_tag_search]} |
| 40 | mode={:tags} | |
| 41 | label="" | |
| 42 | options={[]} | |
| 43 | placeholder="Add tag" | |
| 44 | debounce={80} | |
| 45 | clear_tag_button_class="cursor-pointer px-1 rounded-r-md" | |
| 46 | dropdown_extra_class="bg-white max-h-48 overflow-y-scroll" | |
| 47 | tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold" | |
| 48 | tags_container_class="flex flex-wrap gap-2" | |
| 49 | container_extra_class="rounded border border-none" | |
| 50 | update_min_len={1} | |
| 51 | user_defined_options="true" | |
| 52 | 7 | value={@selected_tags} |
| 53 | phx-blur="ls_tag_search_blur" | |
| 54 | 7 | phx-target={@myself} |
| 55 | > | |
| 56 | 0 | <:option :let={option}> |
| 57 | 0 | <div class="flex" title={option.description}> |
| 58 | 0 | <%= option.label %> |
| 59 | </div> | |
| 60 | </:option> | |
| 61 | 0 | <:tag :let={option}> |
| 62 | 0 | <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}> |
| 63 | 0 | <.link navigate={~p"/tags/#{option.value}"}> |
| 64 | 0 | <%= option.label %> |
| 65 | </.link> | |
| 66 | </div> | |
| 67 | </:tag> | |
| 68 | </.live_select> | |
| 69 | </div> | |
| 70 | ||
| 71 | <div | |
| 72 | id="tag-selector__colour-select" | |
| 73 | class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0" | |
| 74 | > | |
| 75 | 7 | <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} /> |
| 76 | </div> | |
| 77 | ||
| 78 | 3 | <.button |
| 79 | id="tag-selector__add-button" | |
| 80 | class="add-tag-button flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md" | |
| 81 | type="button" | |
| 82 | phx-click={enable_tag_selector()} | |
| 83 | > | |
| 84 | Add tag + | |
| 85 | </.button> | |
| 86 | </div> | |
| 87 | ||
| 88 | 7 | <:actions> |
| 89 | 7 | <.button phx-disable-with="Saving...">Save</.button> |
| 90 | </:actions> | |
| 91 | </.simple_form> | |
| 92 | </div> | |
| 93 | """ | |
| 94 | end | |
| 95 | ||
| 96 | @impl true | |
| 97 | 3 | def update(%{project: project} = assigns, socket) do |
| 98 | 3 | changeset = Projects.change_project(project) |
| 99 | ||
| 100 | 3 | project = project |> Klepsidra.Repo.preload(:tags) |
| 101 | ||
| 102 | 3 | socket = |
| 103 | socket | |
| 104 | |> TagUtilities.generate_tag_options( | |
| 105 | [], | |
| 106 | 3 | Enum.map(project.tags, fn tag -> tag.id end), |
| 107 | @tag_search_live_component_id | |
| 108 | ) | |
| 109 | |> Phx.Live.Head.push( | |
| 110 | "style[id*=dynamic-style-block]", | |
| 111 | :dynamic, | |
| 112 | "style_declarations", | |
| 113 | 3 | DynamicCSS.generate_tag_styles(project.tags) |
| 114 | ) | |
| 115 | |> assign(assigns) | |
| 116 | |> assign(new_tag_colour: {"#94a3b8", "#fff"}) | |
| 117 | |> assign_form(changeset) | |
| 118 | ||
| 119 | {:ok, socket} | |
| 120 | end | |
| 121 | ||
| 122 | @impl true | |
| 123 | 0 | def handle_event( |
| 124 | "validate", | |
| 125 | %{ | |
| 126 | "_target" => ["project", "ls_tag_search"], | |
| 127 | "project" => %{"ls_tag_search" => tags_applied} | |
| 128 | }, | |
| 129 | socket | |
| 130 | ) do | |
| 131 | 0 | Tag.handle_tag_list_changes( |
| 132 | 0 | socket.assigns.selected_tag_queue, |
| 133 | tags_applied, | |
| 134 | 0 | socket.assigns.project.id, |
| 135 | &Categorisation.add_project_tag(&1, &2), | |
| 136 | &Categorisation.delete_project_tag(&1, &2) | |
| 137 | ) | |
| 138 | ||
| 139 | 0 | socket = |
| 140 | TagUtilities.generate_tag_options( | |
| 141 | socket, | |
| 142 | 0 | socket.assigns.selected_tag_queue, |
| 143 | tags_applied, | |
| 144 | @tag_search_live_component_id, | |
| 145 | 0 | parent_tag_select_id: socket.assigns.parent_tag_select_id |
| 146 | ) | |
| 147 | |> Phx.Live.Head.push( | |
| 148 | "style[id*=dynamic-style-block]", | |
| 149 | :dynamic, | |
| 150 | "style_declarations", | |
| 151 | DynamicCSS.generate_tag_styles(tags_applied) | |
| 152 | ) | |
| 153 | |> assign( | |
| 154 | tag_search_phrase: nil, | |
| 155 | possible_free_tag_entered: false | |
| 156 | ) | |
| 157 | ||
| 158 | {:noreply, socket} | |
| 159 | end | |
| 160 | ||
| 161 | @doc """ | |
| 162 | Validate event which fires only once the last of the tags has been cleared | |
| 163 | from a `live_select` component. | |
| 164 | """ | |
| 165 | 0 | def handle_event( |
| 166 | "validate", | |
| 167 | %{ | |
| 168 | "_target" => ["project", "ls_tag_search_empty_selection"], | |
| 169 | "project" => %{"ls_tag_search_empty_selection" => ""} | |
| 170 | }, | |
| 171 | socket | |
| 172 | ) do | |
| 173 | 0 | Tag.handle_tag_list_changes( |
| 174 | 0 | socket.assigns.selected_tag_queue, |
| 175 | [], | |
| 176 | 0 | socket.assigns.project.id, |
| 177 | &Categorisation.add_project_tag(&1, &2), | |
| 178 | &Categorisation.delete_project_tag(&1, &2) | |
| 179 | ) | |
| 180 | ||
| 181 | 0 | socket.assigns.parent_tag_select_id && |
| 182 | 0 | send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: []) |
| 183 | ||
| 184 | 0 | socket = |
| 185 | socket | |
| 186 | |> assign( | |
| 187 | tag_search_phrase: nil, | |
| 188 | possible_free_tag_entered: false | |
| 189 | ) | |
| 190 | ||
| 191 | {:noreply, socket} | |
| 192 | end | |
| 193 | ||
| 194 | 0 | def handle_event( |
| 195 | "validate", | |
| 196 | %{ | |
| 197 | "_target" => ["project", "bg_colour"], | |
| 198 | "project" => %{ | |
| 199 | "bg_colour" => bg_colour | |
| 200 | } | |
| 201 | }, | |
| 202 | socket | |
| 203 | ) do | |
| 204 | 0 | fg_colour = |
| 205 | case ColorContrast.calc_contrast(bg_colour) do | |
| 206 | 0 | {:ok, fg_colour} -> fg_colour |
| 207 | 0 | {:error, _} -> "#fff" |
| 208 | end | |
| 209 | ||
| 210 | 0 | socket = |
| 211 | socket | |
| 212 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 213 | ||
| 214 | {:noreply, socket} | |
| 215 | end | |
| 216 | ||
| 217 | 3 | def handle_event("validate", %{"project" => project_params}, socket) do |
| 218 | 3 | changeset = |
| 219 | 3 | socket.assigns.project |
| 220 | |> Projects.change_project(project_params) | |
| 221 | |> Map.put(:action, :validate) | |
| 222 | ||
| 223 | {:noreply, assign_form(socket, changeset)} | |
| 224 | end | |
| 225 | ||
| 226 | def handle_event("save", %{"project" => project_params}, socket) do | |
| 227 | 3 | save_project(socket, socket.assigns.action, project_params) |
| 228 | end | |
| 229 | ||
| 230 | 0 | def handle_event( |
| 231 | "live_select_change", | |
| 232 | %{ | |
| 233 | "field" => "project_ls_tag_search", | |
| 234 | "id" => live_select_id, | |
| 235 | "text" => tag_search_phrase | |
| 236 | }, | |
| 237 | socket | |
| 238 | ) do | |
| 239 | 0 | tag_search_results = |
| 240 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 241 | |> TagUtilities.tag_options_for_live_select() | |
| 242 | ||
| 243 | 0 | send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results) |
| 244 | ||
| 245 | 0 | socket = |
| 246 | socket | |
| 247 | |> assign( | |
| 248 | tag_search_phrase: tag_search_phrase, | |
| 249 | possible_free_tag_entered: true | |
| 250 | ) | |
| 251 | ||
| 252 | {:noreply, socket} | |
| 253 | end | |
| 254 | ||
| 255 | 0 | def handle_event( |
| 256 | "ls_tag_search_blur", | |
| 257 | %{"id" => @tag_search_live_component_id}, | |
| 258 | socket | |
| 259 | ) do | |
| 260 | 0 | socket = |
| 261 | socket | |
| 262 | |> assign( | |
| 263 | tag_search_phrase: nil, | |
| 264 | possible_free_tag_entered: false | |
| 265 | ) | |
| 266 | ||
| 267 | {:noreply, socket} | |
| 268 | end | |
| 269 | ||
| 270 | 0 | def handle_event( |
| 271 | "key_up", | |
| 272 | %{"key" => "Enter"}, | |
| 273 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 274 | socket | |
| 275 | ) do | |
| 276 | 0 | socket = |
| 277 | TagUtilities.handle_free_tagging( | |
| 278 | socket, | |
| 279 | tag_search_phrase, | |
| 280 | String.length(tag_search_phrase), | |
| 281 | @tag_search_live_component_id, | |
| 282 | 0 | socket.assigns.new_tag_colour |
| 283 | ) | |
| 284 | ||
| 285 | {:noreply, socket} | |
| 286 | end | |
| 287 | ||
| 288 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 289 | ||
| 290 | 2 | defp save_project(socket, :edit, project_params) do |
| 291 | 2 | case Projects.update_project(socket.assigns.project, project_params) do |
| 292 | {:ok, project} -> | |
| 293 | 2 | notify_parent({:saved, project}) |
| 294 | ||
| 295 | {:noreply, | |
| 296 | socket | |
| 297 | |> put_toast(:info, "Project updated successfully") | |
| 298 | 2 | |> push_patch(to: socket.assigns.patch)} |
| 299 | ||
| 300 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 301 | {:noreply, assign_form(socket, changeset)} | |
| 302 | end | |
| 303 | end | |
| 304 | ||
| 305 | 0 | defp save_project(socket, :new, project_params) do |
| 306 | 1 | case Projects.create_project(project_params) do |
| 307 | {:ok, project} -> | |
| 308 | 0 | notify_parent({:saved, project}) |
| 309 | ||
| 310 | 0 | Tag.handle_tag_list_changes( |
| 311 | [], | |
| 312 | 0 | socket.assigns.selected_tag_queue, |
| 313 | 0 | project.id, |
| 314 | &Categorisation.add_project_tag(&1, &2), | |
| 315 | &Categorisation.delete_project_tag(&1, &2) | |
| 316 | ) | |
| 317 | ||
| 318 | {:noreply, | |
| 319 | socket | |
| 320 | |> put_toast(:info, "Project created successfully") | |
| 321 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 322 | ||
| 323 | 1 | {:error, %Ecto.Changeset{} = changeset} -> |
| 324 | {:noreply, assign_form(socket, changeset)} | |
| 325 | end | |
| 326 | end | |
| 327 | ||
| 328 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 329 | 7 | assign(socket, :form, to_form(changeset)) |
| 330 | end | |
| 331 | ||
| 332 | 2 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 333 | ||
| 334 | defp enable_tag_selector() do | |
| 335 | JS.remove_class("hidden", to: "#project_ls_tag_search_text_input") | |
| 336 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select") | |
| 337 | |> JS.add_class("hidden", to: "#tag-selector__add-button") | |
| 338 | |> JS.add_class("gap-2", to: "#tag-selector") | |
| 339 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select") | |
| 340 | 3 | |> JS.focus(to: "#project_ls_tag_search_text_input") |
| 341 | end | |
| 342 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ProjectLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | import LiveToast | |
| 5 | ||
| 6 | alias Klepsidra.Projects | |
| 7 | alias Klepsidra.Projects.Project | |
| 8 | ||
| 9 | @impl true | |
| 10 | 8 | def mount(_params, _session, socket) do |
| 11 | {:ok, stream(socket, :projects, Projects.list_projects())} | |
| 12 | end | |
| 13 | ||
| 14 | @impl true | |
| 15 | 11 | def handle_params(params, _url, socket) do |
| 16 | 11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 17 | end | |
| 18 | ||
| 19 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 20 | socket | |
| 21 | |> assign(:page_title, "Edit Project") | |
| 22 | 1 | |> assign(:project, Projects.get_project!(id)) |
| 23 | end | |
| 24 | ||
| 25 | defp apply_action(socket, :new, _params) do | |
| 26 | socket | |
| 27 | |> assign(:page_title, "New Project") | |
| 28 | 1 | |> assign(:project, %Project{}) |
| 29 | end | |
| 30 | ||
| 31 | defp apply_action(socket, :index, _params) do | |
| 32 | socket | |
| 33 | |> assign(:page_title, "Projects") | |
| 34 | 9 | |> assign(:project, nil) |
| 35 | end | |
| 36 | ||
| 37 | @impl true | |
| 38 | 1 | def handle_info({KlepsidraWeb.ProjectLive.FormComponent, {:saved, project}}, socket) do |
| 39 | {:noreply, stream_insert(socket, :projects, project)} | |
| 40 | end | |
| 41 | ||
| 42 | @impl true | |
| 43 | 1 | def handle_event("delete", %{"id" => id}, socket) do |
| 44 | 1 | project = Projects.get_project!(id) |
| 45 | 1 | {:ok, _} = Projects.delete_project(project) |
| 46 | ||
| 47 | {:noreply, handle_deleted_project(socket, project, :projects)} | |
| 48 | end | |
| 49 | ||
| 50 | defp handle_deleted_project(socket, proiect, source_stream) do | |
| 51 | socket | |
| 52 | |> stream_delete(source_stream, proiect) | |
| 53 | 1 | |> put_toast(:info, "Project deleted successfully") |
| 54 | end | |
| 55 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ProjectLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Projects | |
| 6 | alias Klepsidra.TimeTracking.Timer | |
| 7 | alias Klepsidra.Cldr.Unit | |
| 8 | alias Klepsidra.Categorisation | |
| 9 | alias Klepsidra.Categorisation.Tag | |
| 10 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 11 | alias LiveSelect.Component | |
| 12 | alias Klepsidra.DynamicCSS | |
| 13 | ||
| 14 | defmodule TagSearch do | |
| 15 | @moduledoc """ | |
| 16 | The `TagSearch` module defines an embedded `tag_search` schema | |
| 17 | containing the tags for this project. | |
| 18 | """ | |
| 19 | use Ecto.Schema | |
| 20 | ||
| 21 | import Ecto.Changeset | |
| 22 | ||
| 23 | @type t :: %__MODULE__{ | |
| 24 | tag_search: Tag.t() | |
| 25 | } | |
| 26 | 12 | embedded_schema do |
| 27 | embeds_many(:tag_search, Tag, on_replace: :delete) | |
| 28 | field(:bg_colour, :string) | |
| 29 | end | |
| 30 | ||
| 31 | @doc false | |
| 32 | def changeset(schema \\ %__MODULE__{}, params) do | |
| 33 | cast(schema, params, []) | |
| 34 | 4 | |> cast_embed(:tag_search) |
| 35 | end | |
| 36 | end | |
| 37 | ||
| 38 | @tag_search_live_component_id "tag_form_tag_search_live_select_component" | |
| 39 | ||
| 40 | @impl true | |
| 41 | 4 | def mount(params, _session, socket) do |
| 42 | 4 | project_id = Map.get(params, "id") |
| 43 | ||
| 44 | 4 | project = Klepsidra.Projects.get_project!(project_id) |> Klepsidra.Repo.preload(:tags) |
| 45 | ||
| 46 | 4 | socket = |
| 47 | socket | |
| 48 | |> TagUtilities.generate_tag_options( | |
| 49 | [], | |
| 50 | 4 | Enum.map(project.tags, fn tag -> tag.id end), |
| 51 | @tag_search_live_component_id | |
| 52 | ) | |
| 53 | |> Phx.Live.Head.push( | |
| 54 | "style[id*=dynamic-style-block]", | |
| 55 | :dynamic, | |
| 56 | "style_declarations", | |
| 57 | 4 | DynamicCSS.generate_tag_styles(project.tags) |
| 58 | ) | |
| 59 | |> assign( | |
| 60 | live_select_form: to_form(TagSearch.changeset(%{}), as: "tag_form"), | |
| 61 | new_tag_colour: {"#94a3b8", "#fff"} | |
| 62 | ) | |
| 63 | ||
| 64 | {:ok, socket} | |
| 65 | end | |
| 66 | ||
| 67 | @impl true | |
| 68 | 6 | def handle_params(%{"id" => id}, _, socket) do |
| 69 | 6 | aggregate_project_duration = |
| 70 | get_aggregate_duration_for_project(id) | |
| 71 | ||
| 72 | 6 | socket = |
| 73 | socket | |
| 74 | 6 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 75 | |> assign(:project, Projects.get_project!(id)) | |
| 76 | |> assign( | |
| 77 | 6 | aggregate_project_duration: aggregate_project_duration.base_unit_duration, |
| 78 | 6 | duration_in_hours: aggregate_project_duration.duration_in_hours, |
| 79 | 6 | human_readable_duration: aggregate_project_duration.human_readable_duration |
| 80 | ) | |
| 81 | ||
| 82 | {:noreply, socket} | |
| 83 | end | |
| 84 | ||
| 85 | @impl true | |
| 86 | 0 | def handle_event( |
| 87 | "live_select_change", | |
| 88 | %{ | |
| 89 | "field" => "tag_form_tag_search", | |
| 90 | "id" => @tag_search_live_component_id, | |
| 91 | "text" => tag_search_phrase | |
| 92 | }, | |
| 93 | socket | |
| 94 | ) do | |
| 95 | 0 | tag_search_results = |
| 96 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 97 | |> TagUtilities.tag_options_for_live_select() | |
| 98 | ||
| 99 | 0 | send_update(Component, |
| 100 | id: @tag_search_live_component_id, | |
| 101 | options: tag_search_results | |
| 102 | ) | |
| 103 | ||
| 104 | 0 | socket = |
| 105 | socket | |
| 106 | |> assign( | |
| 107 | tag_search_phrase: tag_search_phrase, | |
| 108 | possible_free_tag_entered: true | |
| 109 | ) | |
| 110 | ||
| 111 | {:noreply, socket} | |
| 112 | end | |
| 113 | ||
| 114 | 0 | def handle_event( |
| 115 | "change", | |
| 116 | %{ | |
| 117 | "_target" => ["tag_form", "tag_search_empty_selection"], | |
| 118 | "tag_form" => %{ | |
| 119 | "tag_search_empty_selection" => "", | |
| 120 | "tag_search_text_input" => _tag_search_phrase | |
| 121 | } | |
| 122 | }, | |
| 123 | socket | |
| 124 | ) do | |
| 125 | 0 | Tag.handle_tag_list_changes( |
| 126 | 0 | socket.assigns.selected_tag_queue, |
| 127 | [], | |
| 128 | 0 | socket.assigns.project.id, |
| 129 | &Categorisation.add_project_tag(&1, &2), | |
| 130 | &Categorisation.delete_project_tag(&1, &2) | |
| 131 | ) | |
| 132 | ||
| 133 | 0 | socket = |
| 134 | socket | |
| 135 | |> assign( | |
| 136 | tag_search_phrase: nil, | |
| 137 | possible_free_tag_entered: false | |
| 138 | ) | |
| 139 | ||
| 140 | {:noreply, socket} | |
| 141 | end | |
| 142 | ||
| 143 | 0 | def handle_event( |
| 144 | "change", | |
| 145 | %{ | |
| 146 | "_target" => ["tag_form", "tag_search"], | |
| 147 | "tag_form" => %{ | |
| 148 | "tag_search" => selected_tags, | |
| 149 | "tag_search_text_input" => _tag_search_phrase | |
| 150 | } | |
| 151 | }, | |
| 152 | socket | |
| 153 | ) do | |
| 154 | 0 | Tag.handle_tag_list_changes( |
| 155 | 0 | socket.assigns.selected_tag_queue, |
| 156 | selected_tags, | |
| 157 | 0 | socket.assigns.project.id, |
| 158 | &Categorisation.add_project_tag(&1, &2), | |
| 159 | &Categorisation.delete_project_tag(&1, &2) | |
| 160 | ) | |
| 161 | ||
| 162 | 0 | socket = |
| 163 | TagUtilities.generate_tag_options( | |
| 164 | socket, | |
| 165 | 0 | socket.assigns.selected_tag_queue, |
| 166 | selected_tags, | |
| 167 | @tag_search_live_component_id | |
| 168 | ) | |
| 169 | |> Phx.Live.Head.push( | |
| 170 | "style[id*=dynamic-style-block]", | |
| 171 | :dynamic, | |
| 172 | "style_declarations", | |
| 173 | DynamicCSS.generate_tag_styles(selected_tags) | |
| 174 | ) | |
| 175 | |> assign( | |
| 176 | tag_search_phrase: nil, | |
| 177 | possible_free_tag_entered: false | |
| 178 | ) | |
| 179 | ||
| 180 | {:noreply, socket} | |
| 181 | end | |
| 182 | ||
| 183 | 0 | def handle_event( |
| 184 | "change", | |
| 185 | %{ | |
| 186 | "_target" => ["tag_form", "bg_colour"], | |
| 187 | "tag_form" => %{ | |
| 188 | "bg_colour" => bg_colour, | |
| 189 | "tag_search_text_input" => _tag_search_phrase | |
| 190 | } | |
| 191 | }, | |
| 192 | socket | |
| 193 | ) do | |
| 194 | 0 | fg_colour = |
| 195 | case ColorContrast.calc_contrast(bg_colour) do | |
| 196 | 0 | {:ok, fg_colour} -> fg_colour |
| 197 | 0 | {:error, _} -> "#fff" |
| 198 | end | |
| 199 | ||
| 200 | 0 | socket = |
| 201 | socket | |
| 202 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 203 | ||
| 204 | {:noreply, socket} | |
| 205 | end | |
| 206 | ||
| 207 | 0 | def handle_event( |
| 208 | "ls_tag_search_blur", | |
| 209 | %{"id" => @tag_search_live_component_id}, | |
| 210 | socket | |
| 211 | ) do | |
| 212 | 0 | socket = |
| 213 | socket | |
| 214 | |> assign( | |
| 215 | tag_search_phrase: nil, | |
| 216 | possible_free_tag_entered: false | |
| 217 | ) | |
| 218 | ||
| 219 | {:noreply, socket} | |
| 220 | end | |
| 221 | ||
| 222 | 0 | def handle_event( |
| 223 | "key_up", | |
| 224 | %{"key" => "Enter"}, | |
| 225 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 226 | socket | |
| 227 | ) do | |
| 228 | 0 | socket = |
| 229 | TagUtilities.handle_free_tagging( | |
| 230 | socket, | |
| 231 | tag_search_phrase, | |
| 232 | String.length(tag_search_phrase), | |
| 233 | @tag_search_live_component_id, | |
| 234 | 0 | socket.assigns.new_tag_colour |
| 235 | ) | |
| 236 | ||
| 237 | {:noreply, socket} | |
| 238 | end | |
| 239 | ||
| 240 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 241 | ||
| 242 | 5 | defp page_title(:show), do: "Show Project" |
| 243 | 1 | defp page_title(:edit), do: "Edit Project" |
| 244 | ||
| 245 | @impl true | |
| 246 | 1 | def handle_info({KlepsidraWeb.ProjectLive.FormComponent, {:saved, _project}}, socket) do |
| 247 | {:noreply, socket} | |
| 248 | end | |
| 249 | ||
| 250 | defp get_aggregate_duration_for_project(project_id) do | |
| 251 | project_id | |
| 252 | |> Klepsidra.TimeTracking.get_closed_timer_durations_for_project() | |
| 253 | |> Timer.convert_durations_to_base_time_unit() | |
| 254 | |> Timer.sum_base_unit_durations() | |
| 255 | 6 | |> format_aggregate_duration_for_project() |
| 256 | end | |
| 257 | ||
| 258 | defp format_aggregate_duration_for_project(base_unit_duration) do | |
| 259 | 6 | %{ |
| 260 | base_unit_duration: base_unit_duration, | |
| 261 | duration_in_hours: | |
| 262 | base_unit_duration | |
| 263 | |> Unit.convert!(:hour) | |
| 264 | 6 | |> then(fn i -> Cldr.Unit.round(i, 1) end) |
| 265 | |> Unit.to_string!(), | |
| 266 | human_readable_duration: | |
| 267 | Timer.format_human_readable_duration(base_unit_duration, | |
| 268 | unit_list: [ | |
| 269 | :day, | |
| 270 | :hour_increment | |
| 271 | ], | |
| 272 | return_if_short_duration: false | |
| 273 | ) | |
| 274 | } | |
| 275 | end | |
| 276 | ||
| 277 | defp enable_tag_selector() do | |
| 278 | JS.remove_class("hidden", to: "#tag_form_tag_search_text_input") | |
| 279 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select--show") | |
| 280 | |> JS.add_class("hidden", to: "#tag-selector__add-button--show") | |
| 281 | |> JS.add_class("gap-2", to: "#tag-selector--show") | |
| 282 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select--show") | |
| 283 | 4 | |> JS.focus(to: "#tag_form_tag_search_text_input") |
| 284 | end | |
| 285 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TimerLive.ActivityTimeReporting do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.TimeTracking | |
| 6 | import LiveToast | |
| 7 | ||
| 8 | @impl true | |
| 9 | 0 | def mount(_params, _session, socket) do |
| 10 | 0 | filter = %{ |
| 11 | from: "", | |
| 12 | to: "", | |
| 13 | project_id: "", | |
| 14 | business_partner_id: "", | |
| 15 | activity_type_id: "", | |
| 16 | billable: "", | |
| 17 | modified: "" | |
| 18 | } | |
| 19 | ||
| 20 | 0 | projects = Klepsidra.Projects.list_active_projects() |
| 21 | 0 | customers = Klepsidra.BusinessPartners.list_active_customers() |
| 22 | 0 | activity_types = Klepsidra.TimeTracking.list_active_activity_types() |
| 23 | 0 | filtered_timers = TimeTracking.list_timers_with_statistics(filter) |
| 24 | ||
| 25 | {:ok, | |
| 26 | socket | |
| 27 | |> assign( | |
| 28 | display_help: false, | |
| 29 | filter: filter, | |
| 30 | projects: projects, | |
| 31 | customers: customers, | |
| 32 | activity_types: activity_types, | |
| 33 | 0 | timer_count: filtered_timers.meta.timer_count, |
| 34 | 0 | aggregate_duration: filtered_timers.meta.aggregate_duration, |
| 35 | 0 | average_duration: filtered_timers.meta.average_timer_duration, |
| 36 | 0 | aggregate_billing_duration: filtered_timers.meta.aggregate_billing_duration |
| 37 | ) | |
| 38 | 0 | |> stream(:timers, filtered_timers.timer_list)} |
| 39 | end | |
| 40 | ||
| 41 | @impl true | |
| 42 | 0 | def handle_params(params, _url, socket) do |
| 43 | 0 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 44 | end | |
| 45 | ||
| 46 | defp apply_action(socket, :edit_timer, %{"id" => id}) do | |
| 47 | socket | |
| 48 | |> assign(:page_title, "Edit Timer") | |
| 49 | 0 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 50 | end | |
| 51 | ||
| 52 | defp apply_action(socket, :index, _params) do | |
| 53 | socket | |
| 54 | |> assign(:page_title, "Activity Timers") | |
| 55 | 0 | |> assign(:timer, nil) |
| 56 | end | |
| 57 | ||
| 58 | defp apply_action(socket, :new_note, %{"id" => id} = _params) do | |
| 59 | socket | |
| 60 | |> assign(:page_title, "New note") | |
| 61 | 0 | |> assign(:timer_id, id) |
| 62 | end | |
| 63 | ||
| 64 | @impl true | |
| 65 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_open_timer, timer}}, socket) do |
| 66 | {:noreply, handle_open_timer(socket, timer)} | |
| 67 | end | |
| 68 | ||
| 69 | @impl true | |
| 70 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do |
| 71 | {:noreply, handle_closed_timer(socket, timer)} | |
| 72 | end | |
| 73 | ||
| 74 | @impl true | |
| 75 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, timer}}, socket) do |
| 76 | {:noreply, handle_updated_timer(socket, timer)} | |
| 77 | end | |
| 78 | ||
| 79 | @impl true | |
| 80 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, timer}}, socket) do |
| 81 | {:noreply, handle_updated_timer(socket, timer)} | |
| 82 | end | |
| 83 | ||
| 84 | @impl true | |
| 85 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_started, timer}}, socket) do |
| 86 | {:noreply, handle_started_timer(socket, timer)} | |
| 87 | end | |
| 88 | ||
| 89 | @impl true | |
| 90 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do |
| 91 | {:noreply, handle_closed_timer(socket, timer)} | |
| 92 | end | |
| 93 | ||
| 94 | @impl true | |
| 95 | 0 | def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do |
| 96 | {:noreply, handle_saved_note(socket, note)} | |
| 97 | end | |
| 98 | ||
| 99 | @impl true | |
| 100 | 0 | def handle_event("delete", %{"id" => id}, socket) do |
| 101 | 0 | timer = TimeTracking.get_timer!(id) |
| 102 | 0 | {:ok, _} = TimeTracking.delete_timer(timer) |
| 103 | ||
| 104 | {:noreply, handle_deleted_timer(socket, timer, :timers)} | |
| 105 | end | |
| 106 | ||
| 107 | @impl true | |
| 108 | 0 | def handle_event( |
| 109 | "filter", | |
| 110 | %{ | |
| 111 | "from" => from, | |
| 112 | "to" => to, | |
| 113 | "project_id" => project_id, | |
| 114 | "business_partner_id" => business_partner_id, | |
| 115 | "activity_type_id" => activity_type_id, | |
| 116 | "billable" => billable, | |
| 117 | "modified" => modified | |
| 118 | }, | |
| 119 | socket | |
| 120 | ) do | |
| 121 | 0 | from = parse_date(from) |
| 122 | 0 | to = parse_date(to) |
| 123 | ||
| 124 | 0 | filter = %{ |
| 125 | from: from, | |
| 126 | to: to, | |
| 127 | project_id: project_id, | |
| 128 | business_partner_id: business_partner_id, | |
| 129 | activity_type_id: activity_type_id, | |
| 130 | billable: billable, | |
| 131 | modified: modified | |
| 132 | } | |
| 133 | ||
| 134 | 0 | filtered_timers = TimeTracking.list_timers_with_statistics(filter) |
| 135 | ||
| 136 | 0 | socket = |
| 137 | socket | |
| 138 | |> assign( | |
| 139 | filter: filter, | |
| 140 | 0 | timer_count: filtered_timers.meta.timer_count, |
| 141 | 0 | aggregate_duration: filtered_timers.meta.aggregate_duration, |
| 142 | 0 | average_duration: filtered_timers.meta.average_timer_duration, |
| 143 | 0 | aggregate_billing_duration: filtered_timers.meta.aggregate_billing_duration |
| 144 | ) | |
| 145 | 0 | |> stream(:timers, filtered_timers.timer_list, reset: true) |
| 146 | ||
| 147 | {:noreply, socket} | |
| 148 | end | |
| 149 | ||
| 150 | defp handle_started_timer(socket, timer) do | |
| 151 | socket | |
| 152 | |> stream_insert(:timers, timer, at: 0) | |
| 153 | 0 | |> put_toast(:info, "Timer started") |
| 154 | end | |
| 155 | ||
| 156 | defp handle_open_timer(socket, timer) do | |
| 157 | socket | |
| 158 | |> stream_insert(:timers, timer) | |
| 159 | 0 | |> put_toast(:info, "Timer created successfully") |
| 160 | end | |
| 161 | ||
| 162 | defp handle_closed_timer(socket, timer) do | |
| 163 | socket | |
| 164 | |> stream_insert(:timers, timer) | |
| 165 | 0 | |> put_toast(:info, "Timer stopped") |
| 166 | end | |
| 167 | ||
| 168 | defp handle_updated_timer(socket, timer) do | |
| 169 | socket | |
| 170 | |> stream_insert(:timers, timer) | |
| 171 | 0 | |> put_toast(:info, "Timer updated successfully") |
| 172 | end | |
| 173 | ||
| 174 | defp handle_deleted_timer(socket, timer, source_stream) do | |
| 175 | socket | |
| 176 | |> stream_delete(source_stream, timer) | |
| 177 | 0 | |> put_toast(:info, "Timer deleted successfully") |
| 178 | end | |
| 179 | ||
| 180 | defp handle_saved_note(socket, _note) do | |
| 181 | socket | |
| 182 | 0 | |> put_toast(:info, "Note created successfully") |
| 183 | end | |
| 184 | ||
| 185 | 0 | defp parse_date(""), do: "" |
| 186 | ||
| 187 | defp parse_date(date) when is_bitstring(date) do | |
| 188 | 0 | Timex.parse!(date, "{YYYY}-{0M}-{D}") |> NaiveDateTime.to_string() |
| 189 | end | |
| 190 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.StartPageLive do | |
| 1 | @moduledoc """ | |
| 2 | Klepsidra's home page, where every user is expected to start their | |
| 3 | interaction with the application. | |
| 4 | ||
| 5 | The 'today' view is available on this page, listing all the timers active | |
| 6 | today, as well as any other pertinent information for daily use. | |
| 7 | """ | |
| 8 | ||
| 9 | use KlepsidraWeb, :live_view | |
| 10 | import LiveToast | |
| 11 | alias Klepsidra.TimeTracking | |
| 12 | alias Klepsidra.TimeTracking.Timer | |
| 13 | alias Klepsidra.TimeTracking.TimeUnits, as: Units | |
| 14 | ||
| 15 | @impl true | |
| 16 | 0 | def mount(_params, _session, socket) do |
| 17 | 0 | current_datetime_stamp = get_current_datetime_stamp() |
| 18 | 0 | aggregate_duration = get_aggregate_duration_for_date(current_datetime_stamp) |
| 19 | ||
| 20 | 0 | human_readable_duration = |
| 21 | Timer.format_human_readable_duration(aggregate_duration, [ | |
| 22 | :hour_increment, | |
| 23 | :minute_increment | |
| 24 | ]) | |
| 25 | ||
| 26 | 0 | open_timer_count = TimeTracking.get_open_timer_count() |
| 27 | 0 | closed_timer_count = TimeTracking.get_closed_timer_count_for_date(current_datetime_stamp) |
| 28 | 0 | today = format_date(current_datetime_stamp) |
| 29 | ||
| 30 | 0 | socket = |
| 31 | socket | |
| 32 | |> assign(:today, today) | |
| 33 | |> assign( | |
| 34 | aggregate_duration: aggregate_duration, | |
| 35 | human_readable_duration: human_readable_duration, | |
| 36 | open_timer_count: open_timer_count, | |
| 37 | closed_timer_count: closed_timer_count | |
| 38 | ) | |
| 39 | |> stream(:open_timers, TimeTracking.get_all_open_timers()) | |
| 40 | |> stream(:closed_timers, TimeTracking.get_closed_timers_for_date(current_datetime_stamp)) | |
| 41 | ||
| 42 | {:ok, socket} | |
| 43 | end | |
| 44 | ||
| 45 | @impl true | |
| 46 | 0 | def handle_params(params, _url, socket) do |
| 47 | 0 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 48 | end | |
| 49 | ||
| 50 | defp apply_action(socket, :new_timer, _params) do | |
| 51 | socket | |
| 52 | |> assign(:page_title, "Manual Timer") | |
| 53 | 0 | |> assign(:timer, %Timer{}) |
| 54 | end | |
| 55 | ||
| 56 | defp apply_action(socket, :show_timer, _params) do | |
| 57 | socket | |
| 58 | |> assign(:page_title, "Manual Timer") | |
| 59 | 0 | |> assign(:timer, %Timer{}) |
| 60 | end | |
| 61 | ||
| 62 | defp apply_action(socket, :edit_timer, %{"id" => id}) do | |
| 63 | socket | |
| 64 | |> assign(:page_title, "Edit Timer") | |
| 65 | 0 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 66 | end | |
| 67 | ||
| 68 | defp apply_action(socket, :start_timer, _params) do | |
| 69 | 0 | billing_duration_unit = Units.get_default_billing_increment() |
| 70 | ||
| 71 | socket | |
| 72 | |> assign(:page_title, "Starting Timer") | |
| 73 | |> assign( | |
| 74 | duration_unit: "minute", | |
| 75 | billing_duration_unit: billing_duration_unit | |
| 76 | ) | |
| 77 | 0 | |> assign(:timer, %Timer{}) |
| 78 | end | |
| 79 | ||
| 80 | defp apply_action(socket, :stop_timer, %{"id" => id}) do | |
| 81 | 0 | start_timestamp = TimeTracking.get_timer!(id).start_stamp |
| 82 | 0 | clocked_out = Timer.clock_out(start_timestamp, :minute) |
| 83 | 0 | billing_duration_unit = Units.get_default_billing_increment() |
| 84 | ||
| 85 | 0 | billing_duration = |
| 86 | Timer.calculate_timer_duration( | |
| 87 | start_timestamp, | |
| 88 | 0 | clocked_out.end_timestamp, |
| 89 | String.to_existing_atom(billing_duration_unit) | |
| 90 | ) | |
| 91 | ||
| 92 | socket | |
| 93 | |> assign(:page_title, "Clock out") | |
| 94 | |> assign( | |
| 95 | clocked_out: clocked_out, | |
| 96 | duration_unit: "minute", | |
| 97 | billing_duration: billing_duration, | |
| 98 | billing_duration_unit: billing_duration_unit | |
| 99 | ) | |
| 100 | 0 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 101 | end | |
| 102 | ||
| 103 | defp apply_action(socket, :index, _params) do | |
| 104 | socket | |
| 105 | |> assign(:page_title, "Activity Timers") | |
| 106 | 0 | |> assign(:timer, nil) |
| 107 | end | |
| 108 | ||
| 109 | defp apply_action(socket, :new_note, %{"id" => id} = _params) do | |
| 110 | socket | |
| 111 | |> assign(:page_title, "New note") | |
| 112 | 0 | |> assign(:timer_id, id) |
| 113 | end | |
| 114 | ||
| 115 | 0 | defp apply_action(socket, nil, _params), do: socket |
| 116 | ||
| 117 | @impl true | |
| 118 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_open_timer, timer}}, socket) do |
| 119 | {:noreply, handle_open_timer(socket, timer)} | |
| 120 | end | |
| 121 | ||
| 122 | @impl true | |
| 123 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do |
| 124 | {:noreply, handle_closed_timer(socket, timer)} | |
| 125 | end | |
| 126 | ||
| 127 | @impl true | |
| 128 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, timer}}, socket) do |
| 129 | {:noreply, handle_updated_timer(socket, timer)} | |
| 130 | end | |
| 131 | ||
| 132 | @impl true | |
| 133 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, timer}}, socket) do |
| 134 | {:noreply, handle_updated_timer(socket, timer)} | |
| 135 | end | |
| 136 | ||
| 137 | @impl true | |
| 138 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_started, timer}}, socket) do |
| 139 | {:noreply, handle_started_timer(socket, timer)} | |
| 140 | end | |
| 141 | ||
| 142 | @impl true | |
| 143 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do |
| 144 | {:noreply, handle_closed_timer(socket, timer)} | |
| 145 | end | |
| 146 | ||
| 147 | @impl true | |
| 148 | 0 | def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do |
| 149 | {:noreply, handle_saved_note(socket, note)} | |
| 150 | end | |
| 151 | ||
| 152 | @impl true | |
| 153 | 0 | def handle_event("delete-open-timer", %{"id" => id}, socket) do |
| 154 | 0 | timer = TimeTracking.get_timer!(id) |
| 155 | 0 | {:ok, _} = TimeTracking.delete_timer(timer) |
| 156 | ||
| 157 | 0 | socket = |
| 158 | socket | |
| 159 | 0 | |> update(:open_timer_count, fn tc -> tc - 1 end) |
| 160 | ||
| 161 | {:noreply, handle_deleted_timer(socket, timer, :open_timers)} | |
| 162 | end | |
| 163 | ||
| 164 | @impl true | |
| 165 | 0 | def handle_event("delete-closed-timer", %{"id" => id}, socket) do |
| 166 | 0 | timer = TimeTracking.get_timer!(id) |
| 167 | 0 | deleted_timer_duration = {timer.duration, timer.duration_time_unit} |
| 168 | 0 | {:ok, _} = TimeTracking.delete_timer(timer) |
| 169 | ||
| 170 | 0 | socket = |
| 171 | socket | |
| 172 | |> update( | |
| 173 | :aggregate_duration, | |
| 174 | fn aggregate_duration -> | |
| 175 | 0 | update_aggregate_duration(:subtraction, aggregate_duration, deleted_timer_duration) |
| 176 | end | |
| 177 | ) | |
| 178 | |> update(:human_readable_duration, fn _human_readable_duration, assigns -> | |
| 179 | 0 | update_human_readable_duration(assigns.aggregate_duration) |
| 180 | end) | |
| 181 | 0 | |> update(:closed_timer_count, fn tc -> tc - 1 end) |
| 182 | ||
| 183 | {:noreply, handle_deleted_timer(socket, timer, :closed_timers)} | |
| 184 | end | |
| 185 | ||
| 186 | defp handle_started_timer(socket, timer) do | |
| 187 | socket | |
| 188 | |> stream_insert(:open_timers, timer, at: 0) | |
| 189 | 0 | |> update(:open_timer_count, fn tc -> tc + 1 end) |
| 190 | 0 | |> put_toast(:info, "Timer started") |
| 191 | end | |
| 192 | ||
| 193 | defp handle_open_timer(socket, timer) do | |
| 194 | socket | |
| 195 | |> stream_insert(:open_timers, timer) | |
| 196 | 0 | |> update(:open_timer_count, fn tc -> tc + 1 end) |
| 197 | 0 | |> put_toast(:info, "Timer created successfully") |
| 198 | end | |
| 199 | ||
| 200 | defp handle_closed_timer(socket, timer) do | |
| 201 | 0 | closed_timer_duration = {timer.duration, timer.duration_time_unit} |
| 202 | ||
| 203 | socket | |
| 204 | |> update( | |
| 205 | :aggregate_duration, | |
| 206 | fn aggregate_duration -> | |
| 207 | 0 | update_aggregate_duration(:summation, aggregate_duration, closed_timer_duration) |
| 208 | end | |
| 209 | ) | |
| 210 | |> update(:human_readable_duration, fn _human_readable_duration, assigns -> | |
| 211 | 0 | update_human_readable_duration(assigns.aggregate_duration) |
| 212 | end) | |
| 213 | |> stream_delete(:open_timers, timer) | |
| 214 | 0 | |> update(:open_timer_count, fn tc -> tc - 1 end) |
| 215 | |> stream_insert(:closed_timers, timer, at: 0) | |
| 216 | 0 | |> update(:closed_timer_count, fn tc -> tc + 1 end) |
| 217 | 0 | |> put_toast(:info, "Timer stopped") |
| 218 | end | |
| 219 | ||
| 220 | defp handle_updated_timer(socket, timer) do | |
| 221 | 0 | previous_start_stamp = socket.assigns.timer.start_stamp |
| 222 | 0 | previous_end_stamp = socket.assigns.timer.end_stamp |
| 223 | 0 | current_start_stamp = timer.start_stamp |
| 224 | 0 | current_end_stamp = timer.end_stamp |
| 225 | ||
| 226 | 0 | previous_timer_status = |
| 227 | 0 | if previous_start_stamp != "" && previous_end_stamp != "" && not is_nil(previous_end_stamp) do |
| 228 | :closed | |
| 229 | else | |
| 230 | :open | |
| 231 | end | |
| 232 | ||
| 233 | 0 | current_timer_status = |
| 234 | 0 | if current_start_stamp != "" && current_end_stamp != "" && not is_nil(current_end_stamp) do |
| 235 | :closed | |
| 236 | else | |
| 237 | :open | |
| 238 | end | |
| 239 | ||
| 240 | socket | |
| 241 | |> handle_updated_timer_changes(timer, {previous_timer_status, current_timer_status}) | |
| 242 | |> update(:human_readable_duration, fn _human_readable_duration, assigns -> | |
| 243 | 0 | update_human_readable_duration(assigns.aggregate_duration) |
| 244 | end) | |
| 245 | 0 | |> put_toast(:info, "Timer updated successfully") |
| 246 | end | |
| 247 | ||
| 248 | defp handle_updated_timer_changes(socket, timer, {:open, :open}) do | |
| 249 | socket | |
| 250 | |> stream_insert(:open_timers, timer) | |
| 251 | 0 | |> update(:open_timer_count, fn tc -> tc + 1 end) |
| 252 | end | |
| 253 | ||
| 254 | defp handle_updated_timer_changes(socket, timer, {:closed, :closed}) do | |
| 255 | socket | |
| 256 | |> stream_insert(:closed_timers, timer) | |
| 257 | |> update( | |
| 258 | :aggregate_duration, | |
| 259 | fn aggregate_duration -> | |
| 260 | 0 | update_aggregate_duration( |
| 261 | :subtraction, | |
| 262 | aggregate_duration, | |
| 263 | 0 | {socket.assigns.timer.duration, socket.assigns.timer.duration_time_unit} |
| 264 | ) | |
| 265 | end | |
| 266 | ) | |
| 267 | 0 | |> update( |
| 268 | :aggregate_duration, | |
| 269 | fn aggregate_duration -> | |
| 270 | 0 | update_aggregate_duration( |
| 271 | :summation, | |
| 272 | aggregate_duration, | |
| 273 | 0 | {timer.duration, timer.duration_time_unit} |
| 274 | ) | |
| 275 | end | |
| 276 | ) | |
| 277 | end | |
| 278 | ||
| 279 | defp handle_updated_timer_changes(socket, timer, {:open, :closed}) do | |
| 280 | socket | |
| 281 | |> stream_delete(:open_timers, timer) | |
| 282 | 0 | |> update(:open_timer_count, fn tc -> tc - 1 end) |
| 283 | |> stream_insert(:closed_timers, timer) | |
| 284 | 0 | |> update(:closed_timer_count, fn tc -> tc + 1 end) |
| 285 | 0 | |> update(:aggregate_duration, fn aggregate_duration -> |
| 286 | 0 | update_aggregate_duration( |
| 287 | :summation, | |
| 288 | aggregate_duration, | |
| 289 | 0 | {timer.duration, timer.duration_time_unit} |
| 290 | ) | |
| 291 | end) | |
| 292 | end | |
| 293 | ||
| 294 | defp handle_updated_timer_changes(socket, timer, {:closed, :open}) do | |
| 295 | socket | |
| 296 | |> stream_delete(:closed_timers, timer) | |
| 297 | 0 | |> update(:closed_timer_count, fn tc -> tc - 1 end) |
| 298 | |> stream_insert(:open_timers, timer, at: 0) | |
| 299 | 0 | |> update(:open_timer_count, fn tc -> tc + 1 end) |
| 300 | 0 | |> update( |
| 301 | :aggregate_duration, | |
| 302 | fn aggregate_duration -> | |
| 303 | 0 | update_aggregate_duration( |
| 304 | :subtraction, | |
| 305 | aggregate_duration, | |
| 306 | 0 | {socket.assigns.timer.duration, socket.assigns.timer.duration_time_unit} |
| 307 | ) | |
| 308 | end | |
| 309 | ) | |
| 310 | end | |
| 311 | ||
| 312 | defp handle_deleted_timer(socket, timer, source_stream) do | |
| 313 | socket | |
| 314 | |> stream_delete(source_stream, timer) | |
| 315 | 0 | |> put_toast(:info, "Timer deleted successfully") |
| 316 | end | |
| 317 | ||
| 318 | defp handle_saved_note(socket, _note) do | |
| 319 | socket | |
| 320 | 0 | |> put_toast(:info, "Note created successfully") |
| 321 | end | |
| 322 | ||
| 323 | @spec get_current_datetime_stamp() :: NaiveDateTime.t() | |
| 324 | defp get_current_datetime_stamp() do | |
| 325 | Timer.get_current_timestamp() | |
| 326 | 0 | |> NaiveDateTime.beginning_of_day() |
| 327 | end | |
| 328 | ||
| 329 | defp update_aggregate_duration(:summation, starting_aggregate_duration, new_timer_duration) do | |
| 330 | 0 | durations_list = |
| 331 | [starting_aggregate_duration, Timer.convert_duration_to_base_time_unit(new_timer_duration)] | |
| 332 | ||
| 333 | 0 | Timer.sum_base_unit_durations(durations_list) |
| 334 | end | |
| 335 | ||
| 336 | defp update_aggregate_duration( | |
| 337 | :subtraction, | |
| 338 | starting_aggregate_duration, | |
| 339 | deleted_timer_duration | |
| 340 | ) do | |
| 341 | 0 | Timer.subtract_base_unit_durations( |
| 342 | starting_aggregate_duration, | |
| 343 | Timer.convert_duration_to_base_time_unit(deleted_timer_duration) | |
| 344 | ) | |
| 345 | end | |
| 346 | ||
| 347 | defp update_human_readable_duration(new_aggregate_duration) do | |
| 348 | 0 | Timer.format_human_readable_duration(new_aggregate_duration) |
| 349 | end | |
| 350 | ||
| 351 | @spec format_date(datetime_stamp :: NaiveDateTime.t()) :: binary() | |
| 352 | defp format_date(datetime_stamp) do | |
| 353 | 0 | case Timer.format_human_readable_date(datetime_stamp) do |
| 354 | 0 | {:ok, formatted_date} -> formatted_date |
| 355 | 0 | _ -> "" |
| 356 | end | |
| 357 | end | |
| 358 | ||
| 359 | defp get_aggregate_duration_for_date(datetime_stamp) do | |
| 360 | datetime_stamp | |
| 361 | |> Klepsidra.TimeTracking.get_closed_timer_durations_for_date() | |
| 362 | |> Timer.convert_durations_to_base_time_unit() | |
| 363 | 0 | |> Timer.sum_base_unit_durations() |
| 364 | end | |
| 365 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TagLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | import LiveToast | |
| 5 | ||
| 6 | alias Klepsidra.Categorisation | |
| 7 | ||
| 8 | @impl true | |
| 9 | def render(assigns) do | |
| 10 | 7 | ~H""" |
| 11 | <div> | |
| 12 | 3 | <.header> |
| 13 | 3 | <%= @title %> |
| 14 | </.header> | |
| 15 | ||
| 16 | 7 | <.simple_form |
| 17 | 7 | for={@form} |
| 18 | id="tag-form" | |
| 19 | 7 | phx-target={@myself} |
| 20 | phx-change="validate" | |
| 21 | phx-submit="save" | |
| 22 | > | |
| 23 | 7 | <.input field={@form[:name]} type="text" label="Name" /> |
| 24 | 7 | <.input field={@form[:colour]} type="color" label="Colour" /> |
| 25 | 7 | <.input field={@form[:fg_colour]} type="color" label="Text colour" /> |
| 26 | 7 | <.input field={@form[:description]} type="textarea" label="Description" /> |
| 27 | 7 | <:actions> |
| 28 | 7 | <.button phx-disable-with="Saving...">Save</.button> |
| 29 | </:actions> | |
| 30 | </.simple_form> | |
| 31 | </div> | |
| 32 | """ | |
| 33 | end | |
| 34 | ||
| 35 | @impl true | |
| 36 | 3 | def update(%{tag: tag} = assigns, socket) do |
| 37 | 3 | changeset = Categorisation.change_tag(tag) |
| 38 | ||
| 39 | {:ok, | |
| 40 | socket | |
| 41 | |> assign(assigns) | |
| 42 | |> assign_form(changeset)} | |
| 43 | end | |
| 44 | ||
| 45 | @impl true | |
| 46 | 3 | def handle_event("validate", %{"tag" => tag_params}, socket) do |
| 47 | 3 | changeset = |
| 48 | 3 | socket.assigns.tag |
| 49 | |> Categorisation.change_tag(tag_params) | |
| 50 | |> Map.put(:action, :validate) | |
| 51 | ||
| 52 | {:noreply, assign_form(socket, changeset)} | |
| 53 | end | |
| 54 | ||
| 55 | def handle_event("save", %{"tag" => tag_params}, socket) do | |
| 56 | 3 | save_tag(socket, socket.assigns.action, tag_params) |
| 57 | end | |
| 58 | ||
| 59 | 2 | defp save_tag(socket, :edit, tag_params) do |
| 60 | 2 | case Categorisation.update_tag(socket.assigns.tag, tag_params) do |
| 61 | {:ok, tag} -> | |
| 62 | 2 | notify_parent({:saved, tag}) |
| 63 | ||
| 64 | {:noreply, | |
| 65 | socket | |
| 66 | |> put_toast(:info, "Tag updated successfully") | |
| 67 | 2 | |> push_patch(to: socket.assigns.patch)} |
| 68 | ||
| 69 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 70 | {:noreply, assign_form(socket, changeset)} | |
| 71 | end | |
| 72 | end | |
| 73 | ||
| 74 | 0 | defp save_tag(socket, :new, tag_params) do |
| 75 | 1 | case Categorisation.create_tag(tag_params) do |
| 76 | {:ok, tag} -> | |
| 77 | 0 | notify_parent({:saved, tag}) |
| 78 | ||
| 79 | {:noreply, | |
| 80 | socket | |
| 81 | |> put_toast(:info, "Tag created successfully") | |
| 82 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 83 | ||
| 84 | 1 | {:error, %Ecto.Changeset{} = changeset} -> |
| 85 | {:noreply, assign_form(socket, changeset)} | |
| 86 | end | |
| 87 | end | |
| 88 | ||
| 89 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 90 | 7 | assign(socket, :form, to_form(changeset)) |
| 91 | end | |
| 92 | ||
| 93 | 2 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 94 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TagLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | import LiveToast | |
| 5 | ||
| 6 | alias Klepsidra.Categorisation | |
| 7 | alias Klepsidra.Categorisation.Tag | |
| 8 | ||
| 9 | # alias KlepsidraWeb.Live.TagLive.SearchFormComponent | |
| 10 | ||
| 11 | @impl true | |
| 12 | 8 | def mount(_params, _session, socket) do |
| 13 | 8 | socket = |
| 14 | assign(socket, | |
| 15 | search_phrase: "", | |
| 16 | filtered_tags: [], | |
| 17 | matches: [] | |
| 18 | ) | |
| 19 | ||
| 20 | {:ok, stream(socket, :tags, Categorisation.list_tags())} | |
| 21 | end | |
| 22 | ||
| 23 | @impl true | |
| 24 | 11 | def handle_params(params, _url, socket) do |
| 25 | 11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 26 | end | |
| 27 | ||
| 28 | @impl true | |
| 29 | 1 | def handle_event("delete", %{"id" => id}, socket) do |
| 30 | 1 | tag = Categorisation.get_tag!(id) |
| 31 | 1 | {:ok, _} = Categorisation.delete_tag(tag) |
| 32 | ||
| 33 | {:noreply, handle_deleted_tag(socket, tag, :tags)} | |
| 34 | end | |
| 35 | ||
| 36 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 37 | socket | |
| 38 | |> assign(:page_title, "Edit tag") | |
| 39 | 1 | |> assign(:tag, Categorisation.get_tag!(id)) |
| 40 | end | |
| 41 | ||
| 42 | defp apply_action(socket, :new, _params) do | |
| 43 | socket | |
| 44 | |> assign(:page_title, "New tag") | |
| 45 | 1 | |> assign(:tag, %Tag{}) |
| 46 | end | |
| 47 | ||
| 48 | defp apply_action(socket, :index, _params) do | |
| 49 | socket | |
| 50 | |> assign(:page_title, "Tags") | |
| 51 | 9 | |> assign(:tag, nil) |
| 52 | end | |
| 53 | ||
| 54 | @impl true | |
| 55 | 1 | def handle_info({KlepsidraWeb.TagLive.FormComponent, {:saved, tag}}, socket) do |
| 56 | {:noreply, stream_insert(socket, :tags, tag)} | |
| 57 | end | |
| 58 | ||
| 59 | defp handle_deleted_tag(socket, tag, source_stream) do | |
| 60 | socket | |
| 61 | |> stream_delete(source_stream, tag) | |
| 62 | 1 | |> put_toast(:info, "Tag deleted successfully") |
| 63 | end | |
| 64 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TagLive.Show do | |
| 1 | use KlepsidraWeb, :live_view | |
| 2 | ||
| 3 | @moduledoc false | |
| 4 | ||
| 5 | alias Klepsidra.Categorisation | |
| 6 | ||
| 7 | @impl true | |
| 8 | 4 | def mount(_params, _session, socket) do |
| 9 | {:ok, socket} | |
| 10 | end | |
| 11 | ||
| 12 | @impl true | |
| 13 | 6 | def handle_params(%{"id" => id}, _, socket) do |
| 14 | {:noreply, | |
| 15 | socket | |
| 16 | 6 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 17 | |> assign(:tag, Categorisation.get_tag!(id))} | |
| 18 | end | |
| 19 | ||
| 20 | @impl true | |
| 21 | 1 | def handle_info({KlepsidraWeb.TagLive.FormComponent, {:saved, _tag}}, socket) do |
| 22 | {:noreply, socket} | |
| 23 | end | |
| 24 | ||
| 25 | 5 | defp page_title(:show), do: "Show Tag" |
| 26 | 1 | defp page_title(:edit), do: "Edit Tag" |
| 27 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TagLive.TagUtilities do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | alias Klepsidra.Categorisation | |
| 5 | alias Klepsidra.Categorisation.Tag | |
| 6 | alias Klepsidra.DynamicCSS | |
| 7 | ||
| 8 | @doc """ | |
| 9 | Format tag list to label/value map usable by `live_select` component. | |
| 10 | """ | |
| 11 | @spec tag_options_for_live_select(tag_list :: [Tag.t(), ...]) :: [map, ...] | |
| 12 | def tag_options_for_live_select(tag_list) when is_list(tag_list) do | |
| 13 | tag_list | |
| 14 | 0 | |> Enum.map(fn tag -> |
| 15 | 0 | %{ |
| 16 | 0 | label: tag.name, |
| 17 | 0 | value: tag.id, |
| 18 | 0 | description: tag.description, |
| 19 | 0 | tag_class: "tag-#{DynamicCSS.convert_tag_name_to_class(tag.name)}", |
| 20 | 0 | bg_colour: tag.colour || "#94a3b8", |
| 21 | 0 | fg_colour: tag.fg_colour || "#fff" |
| 22 | } | |
| 23 | end) | |
| 24 | end | |
| 25 | ||
| 26 | @doc """ | |
| 27 | Handles creation of new freeform tags, and their immediate selection as | |
| 28 | a chosen tag for the entity. | |
| 29 | """ | |
| 30 | @spec handle_free_tagging( | |
| 31 | socket :: Phoenix.LiveView.Socket.t(), | |
| 32 | tag_search_phrase :: String.t(), | |
| 33 | free_tag_length :: integer(), | |
| 34 | tag_select_id :: String.t(), | |
| 35 | tag_colour :: {String.t(), String.t()}, | |
| 36 | options :: keyword() | |
| 37 | ) :: Phoenix.LiveView.Socket.t() | |
| 38 | def handle_free_tagging( | |
| 39 | socket, | |
| 40 | tag_search_phrase, | |
| 41 | free_tag_length, | |
| 42 | tag_select_id, | |
| 43 | tag_colour, | |
| 44 | 0 | options \\ [] |
| 45 | ) | |
| 46 | ||
| 47 | def handle_free_tagging( | |
| 48 | socket, | |
| 49 | _tag_search_phrase, | |
| 50 | free_tag_length, | |
| 51 | _tag_select_id, | |
| 52 | _tag_colour, | |
| 53 | _options | |
| 54 | ) | |
| 55 | when free_tag_length <= 2, | |
| 56 | 0 | do: socket |
| 57 | ||
| 58 | def handle_free_tagging( | |
| 59 | socket, | |
| 60 | tag_search_phrase, | |
| 61 | _free_tag_length, | |
| 62 | tag_select_id, | |
| 63 | {bg_colour, fg_colour}, | |
| 64 | _options | |
| 65 | ) do | |
| 66 | 0 | tag = |
| 67 | Categorisation.create_or_find_tag(%{ | |
| 68 | name: tag_search_phrase, | |
| 69 | colour: bg_colour, | |
| 70 | fg_colour: fg_colour | |
| 71 | }) | |
| 72 | ||
| 73 | 0 | tags_applied = [tag.id | socket.assigns.selected_tag_queue] |
| 74 | ||
| 75 | 0 | generate_tag_options( |
| 76 | socket, | |
| 77 | 0 | socket.assigns.selected_tag_queue, |
| 78 | tags_applied, | |
| 79 | tag_select_id | |
| 80 | ) | |
| 81 | ||
| 82 | 0 | send_update(LiveSelect.Component, |
| 83 | id: tag_select_id, | |
| 84 | options: [] | |
| 85 | ) | |
| 86 | ||
| 87 | socket | |
| 88 | 0 | |> assign( |
| 89 | tag_search_phrase: nil, | |
| 90 | possible_free_tag_entered: false | |
| 91 | ) | |
| 92 | end | |
| 93 | ||
| 94 | @doc """ | |
| 95 | Takes list of tag IDs, returning full, tag-name sorted, HTML option list | |
| 96 | for `live_select` component. | |
| 97 | """ | |
| 98 | @spec generate_tag_options( | |
| 99 | socket :: Phoenix.LiveView.Socket.t(), | |
| 100 | previous_tag_list :: [Ecto.UUID.t(), ...] | [], | |
| 101 | accumulated_tag_list :: [Ecto.UUID.t(), ...] | [], | |
| 102 | tag_select_id :: String.t(), | |
| 103 | options :: keyword() | |
| 104 | ) :: any() | |
| 105 | def generate_tag_options( | |
| 106 | socket, | |
| 107 | previous_tag_list, | |
| 108 | accumulated_tag_list, | |
| 109 | tag_select_id, | |
| 110 | 11 | options \\ [] |
| 111 | ) | |
| 112 | ||
| 113 | def generate_tag_options( | |
| 114 | %{assigns: %{selected_tags: _selected_tags, selected_tag_queue: _selected_tag_queue}} = | |
| 115 | socket, | |
| 116 | previous_tag_list, | |
| 117 | previous_tag_list, | |
| 118 | _tag_select_id, | |
| 119 | _options | |
| 120 | ), | |
| 121 | 0 | do: socket |
| 122 | ||
| 123 | def generate_tag_options( | |
| 124 | socket, | |
| 125 | previous_tag_list, | |
| 126 | previous_tag_list, | |
| 127 | _tag_select_id, | |
| 128 | _options | |
| 129 | ), | |
| 130 | 11 | do: assign(socket, selected_tags: [], selected_tag_queue: []) |
| 131 | ||
| 132 | def generate_tag_options( | |
| 133 | socket, | |
| 134 | _previous_tag_list, | |
| 135 | accumulated_tag_list, | |
| 136 | tag_select_id, | |
| 137 | options | |
| 138 | ) do | |
| 139 | 0 | parent_tag_select_id = Keyword.get(options, :parent_tag_select_id, nil) |
| 140 | ||
| 141 | 0 | tag_options = |
| 142 | accumulated_tag_list | |
| 143 | |> Categorisation.get_tags!() | |
| 144 | |> tag_options_for_live_select() | |
| 145 | ||
| 146 | 0 | send_update(LiveSelect.Component, id: tag_select_id, value: tag_options) |
| 147 | ||
| 148 | 0 | parent_tag_select_id && |
| 149 | 0 | send_update(LiveSelect.Component, id: parent_tag_select_id, value: tag_options) |
| 150 | ||
| 151 | 0 | assign(socket, selected_tags: tag_options, selected_tag_queue: accumulated_tag_list) |
| 152 | end | |
| 153 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TimerLive.AutomatedTimer do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | ||
| 5 | alias Klepsidra.TimeTracking | |
| 6 | alias Klepsidra.TimeTracking.Timer | |
| 7 | alias Klepsidra.TimeTracking.TimeUnits, as: Units | |
| 8 | alias Klepsidra.Projects.Project | |
| 9 | alias Klepsidra.BusinessPartners.BusinessPartner | |
| 10 | # alias Klepsidra.TimeTracking.ActivityType | |
| 11 | alias Klepsidra.Categorisation | |
| 12 | alias Klepsidra.Categorisation.Tag | |
| 13 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 14 | alias Klepsidra.DynamicCSS | |
| 15 | ||
| 16 | @tag_search_live_component_id "timer_ls_tag_search_live_select_component" | |
| 17 | ||
| 18 | @impl true | |
| 19 | def render(assigns) do | |
| 20 | 0 | ~H""" |
| 21 | <div> | |
| 22 | 0 | <.header> |
| 23 | 0 | <%= @title %> |
| 24 | </.header> | |
| 25 | ||
| 26 | 0 | <.simple_form |
| 27 | 0 | for={@form} |
| 28 | id="timer-form" | |
| 29 | 0 | phx-target={@myself} |
| 30 | phx-change="validate" | |
| 31 | phx-window-keyup="key_up" | |
| 32 | phx-submit="save" | |
| 33 | > | |
| 34 | 0 | <div :if={@invocation_context == :start_timer}> |
| 35 | 0 | <.input |
| 36 | 0 | field={@form[:description]} |
| 37 | type="text" | |
| 38 | label="Description" | |
| 39 | placeholder="What are you working on?" | |
| 40 | autocomplete="off" | |
| 41 | /> | |
| 42 | </div> | |
| 43 | ||
| 44 | 0 | <div :if={@invocation_context == :stop_timer}> |
| 45 | <div class="hidden"> | |
| 46 | 0 | <.input field={@form[:start_stamp]} type="datetime-local" label="Start time" readonly /> |
| 47 | ||
| 48 | 0 | <.input field={@form[:end_stamp]} type="datetime-local" label="End time" readonly /> |
| 49 | </div> | |
| 50 | ||
| 51 | 0 | <.input field={@form[:duration]} type="text" label="Duration" readonly /> |
| 52 | ||
| 53 | 0 | <.input |
| 54 | 0 | field={@form[:duration_time_unit]} |
| 55 | type="select" | |
| 56 | label="Duration time increment" | |
| 57 | options={Units.construct_duration_unit_options_list(use_primitives?: true)} | |
| 58 | /> | |
| 59 | </div> | |
| 60 | ||
| 61 | 0 | <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}> |
| 62 | 0 | <div |
| 63 | id="tag-selector__live-select" | |
| 64 | phx-mounted={JS.add_class("hidden", to: "#timer_ls_tag_search_text_input")} | |
| 65 | > | |
| 66 | 0 | <.live_select |
| 67 | 0 | field={@form[:ls_tag_search]} |
| 68 | mode={:tags} | |
| 69 | label="" | |
| 70 | options={[]} | |
| 71 | placeholder="Add tag" | |
| 72 | debounce={80} | |
| 73 | clear_tag_button_class="cursor-pointer px-1 rounded-r-md" | |
| 74 | dropdown_extra_class="bg-white max-h-48 overflow-y-scroll" | |
| 75 | tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold" | |
| 76 | tags_container_class="flex flex-wrap gap-2" | |
| 77 | container_extra_class="rounded border border-none" | |
| 78 | update_min_len={1} | |
| 79 | user_defined_options="true" | |
| 80 | 0 | value={@selected_tags} |
| 81 | phx-blur="ls_tag_search_blur" | |
| 82 | 0 | phx-target={@myself} |
| 83 | > | |
| 84 | 0 | <:option :let={option}> |
| 85 | 0 | <div class="flex" title={option.description}> |
| 86 | 0 | <%= option.label %> |
| 87 | </div> | |
| 88 | </:option> | |
| 89 | 0 | <:tag :let={option}> |
| 90 | 0 | <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}> |
| 91 | 0 | <.link navigate={~p"/tags/#{option.value}"}> |
| 92 | 0 | <%= option.label %> |
| 93 | </.link> | |
| 94 | </div> | |
| 95 | </:tag> | |
| 96 | </.live_select> | |
| 97 | </div> | |
| 98 | ||
| 99 | <div | |
| 100 | id="tag-selector__colour-select" | |
| 101 | class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0" | |
| 102 | > | |
| 103 | 0 | <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} /> |
| 104 | </div> | |
| 105 | ||
| 106 | 0 | <.button |
| 107 | id="tag-selector__add-button" | |
| 108 | class="flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md" | |
| 109 | type="button" | |
| 110 | phx-click={enable_tag_selector()} | |
| 111 | > | |
| 112 | Add tag + | |
| 113 | </.button> | |
| 114 | </div> | |
| 115 | ||
| 116 | 0 | <div :if={@invocation_context == :stop_timer}> |
| 117 | 0 | <.input |
| 118 | 0 | field={@form[:description]} |
| 119 | type="textarea" | |
| 120 | label="Description" | |
| 121 | placeholder="What did you work on?" | |
| 122 | /> | |
| 123 | </div> | |
| 124 | ||
| 125 | 0 | <.input field={@form[:project_id]} type="select" label="Project" options={@projects} /> |
| 126 | ||
| 127 | 0 | <.input |
| 128 | 0 | field={@form[:business_partner_id]} |
| 129 | type="select" | |
| 130 | label="Customer" | |
| 131 | placeholder="Customer" | |
| 132 | 0 | options={@business_partners} |
| 133 | 0 | required={@billable_activity?} |
| 134 | /> | |
| 135 | ||
| 136 | 0 | <.input field={@form[:billable]} type="checkbox" label="Billable?" /> |
| 137 | ||
| 138 | 0 | <div class={unless @billable_activity? && @invocation_context == :stop_timer, do: "hidden"}> |
| 139 | 0 | <.input field={@form[:billing_duration]} type="text" label="Billable duration" readonly /> |
| 140 | ||
| 141 | 0 | <.input |
| 142 | 0 | field={@form[:billing_duration_time_unit]} |
| 143 | type="select" | |
| 144 | label="Billable time increment" | |
| 145 | options={Units.construct_duration_unit_options_list()} | |
| 146 | /> | |
| 147 | </div> | |
| 148 | ||
| 149 | 0 | <:actions> |
| 150 | 0 | <.button phx-disable-with="Saving..."> |
| 151 | 0 | <%= if @invocation_context == :start_timer, do: "Start timer", else: "Save" %> |
| 152 | </.button> | |
| 153 | </:actions> | |
| 154 | </.simple_form> | |
| 155 | </div> | |
| 156 | """ | |
| 157 | end | |
| 158 | ||
| 159 | @impl true | |
| 160 | @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} | |
| 161 | 0 | def update(%{timer: timer} = assigns, socket) do |
| 162 | 0 | timer = |
| 163 | 0 | case timer.id do |
| 164 | 0 | nil -> timer |> Klepsidra.Repo.preload(:tags) |
| 165 | 0 | _ -> TimeTracking.get_timer!(timer.id) |> Klepsidra.Repo.preload(:tags) |
| 166 | end | |
| 167 | ||
| 168 | 0 | timer_changes = |
| 169 | 0 | case assigns.invocation_context do |
| 170 | :stop_timer -> | |
| 171 | 0 | start_stamp = timer.start_stamp |
| 172 | 0 | end_stamp = Timer.get_current_timestamp() |> Timer.convert_naivedatetime_to_html!() |
| 173 | 0 | duration_time_unit = timer.duration_time_unit |
| 174 | 0 | billing_duration_time_unit = timer.billing_duration_time_unit |
| 175 | ||
| 176 | 0 | duration = |
| 177 | Timer.assign_timer_duration( | |
| 178 | %{ | |
| 179 | "start_stamp" => start_stamp, | |
| 180 | "end_stamp" => end_stamp, | |
| 181 | "duration_time_unit" => duration_time_unit | |
| 182 | }, | |
| 183 | "duration_time_unit" | |
| 184 | ) | |
| 185 | ||
| 186 | 0 | billable = Timer.read_checkbox(timer.billable) |
| 187 | ||
| 188 | 0 | billing_duration = |
| 189 | 0 | if billable do |
| 190 | 0 | Timer.assign_timer_duration( |
| 191 | %{ | |
| 192 | "start_stamp" => start_stamp, | |
| 193 | "end_stamp" => end_stamp, | |
| 194 | "billing_duration_time_unit" => billing_duration_time_unit | |
| 195 | }, | |
| 196 | "billing_duration_time_unit" | |
| 197 | ) | |
| 198 | else | |
| 199 | 0 | |
| 200 | end | |
| 201 | ||
| 202 | 0 | %{ |
| 203 | "end_stamp" => end_stamp, | |
| 204 | "duration" => duration, | |
| 205 | "billing_duration" => billing_duration | |
| 206 | } | |
| 207 | ||
| 208 | _ -> | |
| 209 | 0 | %{} |
| 210 | end | |
| 211 | ||
| 212 | 0 | changeset = TimeTracking.change_timer(timer, timer_changes) |
| 213 | ||
| 214 | 0 | socket = |
| 215 | socket | |
| 216 | |> TagUtilities.generate_tag_options( | |
| 217 | [], | |
| 218 | 0 | Enum.map(timer.tags, fn tag -> tag.id end), |
| 219 | @tag_search_live_component_id | |
| 220 | ) | |
| 221 | |> Phx.Live.Head.push( | |
| 222 | "style[id*=dynamic-style-block]", | |
| 223 | :dynamic, | |
| 224 | "style_declarations", | |
| 225 | 0 | DynamicCSS.generate_tag_styles(timer.tags) |
| 226 | ) | |
| 227 | ||
| 228 | 0 | socket = |
| 229 | socket | |
| 230 | |> assign(assigns) | |
| 231 | |> assign_form(changeset) | |
| 232 | |> assign( | |
| 233 | 0 | billable_activity?: assigns.timer.billable, |
| 234 | new_tag_colour: {"#94a3b8", "#fff"} | |
| 235 | ) | |
| 236 | |> assign_business_partner() | |
| 237 | |> assign_project() | |
| 238 | ||
| 239 | {:ok, socket} | |
| 240 | end | |
| 241 | ||
| 242 | @impl true | |
| 243 | 0 | def handle_event( |
| 244 | "validate", | |
| 245 | %{"_target" => ["timer", "duration_time_unit"], "timer" => timer_params}, | |
| 246 | socket | |
| 247 | ) do | |
| 248 | 0 | changeset = |
| 249 | 0 | socket.assigns.timer |
| 250 | |> TimeTracking.change_timer(%{ | |
| 251 | timer_params | |
| 252 | | "duration" => Timer.assign_timer_duration(timer_params, "duration_time_unit") | |
| 253 | }) | |
| 254 | |> Map.put(:action, :validate) | |
| 255 | ||
| 256 | {:noreply, assign_form(socket, changeset)} | |
| 257 | end | |
| 258 | ||
| 259 | @impl true | |
| 260 | 0 | def handle_event( |
| 261 | "validate", | |
| 262 | %{"_target" => ["timer", "billing_duration_time_unit"], "timer" => timer_params}, | |
| 263 | socket | |
| 264 | ) do | |
| 265 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 266 | ||
| 267 | 0 | billing_duration = |
| 268 | 0 | if billable do |
| 269 | 0 | Timer.assign_timer_duration(timer_params, "billing_duration_time_unit") |
| 270 | else | |
| 271 | 0 | |
| 272 | end | |
| 273 | ||
| 274 | 0 | changeset = |
| 275 | 0 | socket.assigns.timer |
| 276 | |> TimeTracking.change_timer(%{ | |
| 277 | timer_params | |
| 278 | | "billing_duration" => billing_duration | |
| 279 | }) | |
| 280 | |> Map.put(:action, :validate) | |
| 281 | ||
| 282 | {:noreply, assign_form(socket, changeset)} | |
| 283 | end | |
| 284 | ||
| 285 | @impl true | |
| 286 | 0 | def handle_event( |
| 287 | "validate", | |
| 288 | %{"_target" => ["timer", "billable"], "timer" => timer_params}, | |
| 289 | socket | |
| 290 | ) do | |
| 291 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 292 | ||
| 293 | 0 | billing_duration = |
| 294 | 0 | if billable do |
| 295 | 0 | Timer.assign_timer_duration(timer_params, "billing_duration_time_unit") |
| 296 | else | |
| 297 | 0 | |
| 298 | end | |
| 299 | ||
| 300 | 0 | changeset = |
| 301 | 0 | socket.assigns.timer |
| 302 | |> TimeTracking.change_timer(%{ | |
| 303 | timer_params | |
| 304 | | "billing_duration" => billing_duration | |
| 305 | }) | |
| 306 | |> Map.put(:action, :validate) | |
| 307 | ||
| 308 | 0 | socket = |
| 309 | socket | |
| 310 | |> assign(billable_activity?: billable) | |
| 311 | ||
| 312 | {:noreply, assign_form(socket, changeset)} | |
| 313 | end | |
| 314 | ||
| 315 | 0 | def handle_event( |
| 316 | "validate", | |
| 317 | %{"_target" => ["timer", "ls_tag_search"], "timer" => %{"ls_tag_search" => tags_applied}}, | |
| 318 | socket | |
| 319 | ) do | |
| 320 | 0 | Tag.handle_tag_list_changes( |
| 321 | 0 | socket.assigns.selected_tag_queue, |
| 322 | tags_applied, | |
| 323 | 0 | socket.assigns.timer.id, |
| 324 | &Categorisation.add_timer_tag(&1, &2), | |
| 325 | &Categorisation.delete_timer_tag(&1, &2) | |
| 326 | ) | |
| 327 | ||
| 328 | 0 | socket = |
| 329 | TagUtilities.generate_tag_options( | |
| 330 | socket, | |
| 331 | 0 | socket.assigns.selected_tag_queue, |
| 332 | tags_applied, | |
| 333 | @tag_search_live_component_id, | |
| 334 | 0 | parent_tag_select_id: socket.assigns.parent_tag_select_id |
| 335 | ) | |
| 336 | |> Phx.Live.Head.push( | |
| 337 | "style[id*=dynamic-style-block]", | |
| 338 | :dynamic, | |
| 339 | "style_declarations", | |
| 340 | DynamicCSS.generate_tag_styles(tags_applied) | |
| 341 | ) | |
| 342 | ||
| 343 | 0 | socket = |
| 344 | socket | |
| 345 | |> assign( | |
| 346 | tag_search_phrase: nil, | |
| 347 | possible_free_tag_entered: false | |
| 348 | ) | |
| 349 | ||
| 350 | {:noreply, socket} | |
| 351 | end | |
| 352 | ||
| 353 | @doc """ | |
| 354 | Validate event which fires only once the last of the tags has been cleared | |
| 355 | from a `live_select` component. | |
| 356 | """ | |
| 357 | 0 | def handle_event( |
| 358 | "validate", | |
| 359 | %{ | |
| 360 | "_target" => ["timer", "ls_tag_search_empty_selection"], | |
| 361 | "timer" => %{"ls_tag_search_empty_selection" => ""} | |
| 362 | }, | |
| 363 | socket | |
| 364 | ) do | |
| 365 | 0 | Tag.handle_tag_list_changes( |
| 366 | 0 | socket.assigns.selected_tag_queue, |
| 367 | [], | |
| 368 | 0 | socket.assigns.timer.id, |
| 369 | &Categorisation.add_timer_tag(&1, &2), | |
| 370 | &Categorisation.delete_timer_tag(&1, &2) | |
| 371 | ) | |
| 372 | ||
| 373 | 0 | socket.assigns.parent_tag_select_id && |
| 374 | 0 | send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: []) |
| 375 | ||
| 376 | 0 | socket = |
| 377 | socket | |
| 378 | |> assign( | |
| 379 | tag_search_phrase: nil, | |
| 380 | possible_free_tag_entered: false | |
| 381 | ) | |
| 382 | ||
| 383 | {:noreply, socket} | |
| 384 | end | |
| 385 | ||
| 386 | 0 | def handle_event( |
| 387 | "validate", | |
| 388 | %{ | |
| 389 | "_target" => ["timer", "bg_colour"], | |
| 390 | "timer" => %{ | |
| 391 | "bg_colour" => bg_colour | |
| 392 | } | |
| 393 | }, | |
| 394 | socket | |
| 395 | ) do | |
| 396 | 0 | fg_colour = |
| 397 | case ColorContrast.calc_contrast(bg_colour) do | |
| 398 | 0 | {:ok, fg_colour} -> fg_colour |
| 399 | 0 | {:error, _} -> "#fff" |
| 400 | end | |
| 401 | ||
| 402 | 0 | socket = |
| 403 | socket | |
| 404 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 405 | ||
| 406 | {:noreply, socket} | |
| 407 | end | |
| 408 | ||
| 409 | @impl true | |
| 410 | 0 | def handle_event("validate", %{"timer" => timer_params}, socket) do |
| 411 | 0 | changeset = |
| 412 | 0 | socket.assigns.timer |
| 413 | |> TimeTracking.change_timer(timer_params) | |
| 414 | |> Map.put(:action, :validate) | |
| 415 | ||
| 416 | {:noreply, assign_form(socket, changeset)} | |
| 417 | end | |
| 418 | ||
| 419 | def handle_event("save", %{"timer" => timer_params}, socket) do | |
| 420 | 0 | save_timer(socket, socket.assigns.action, timer_params) |
| 421 | end | |
| 422 | ||
| 423 | 0 | def handle_event( |
| 424 | "live_select_change", | |
| 425 | %{ | |
| 426 | "field" => "timer_ls_tag_search", | |
| 427 | "id" => live_select_id, | |
| 428 | "text" => tag_search_phrase | |
| 429 | }, | |
| 430 | socket | |
| 431 | ) do | |
| 432 | 0 | tag_search_results = |
| 433 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 434 | |> TagUtilities.tag_options_for_live_select() | |
| 435 | ||
| 436 | 0 | send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results) |
| 437 | ||
| 438 | 0 | socket = |
| 439 | socket | |
| 440 | |> assign( | |
| 441 | tag_search_phrase: tag_search_phrase, | |
| 442 | possible_free_tag_entered: true | |
| 443 | ) | |
| 444 | ||
| 445 | {:noreply, socket} | |
| 446 | end | |
| 447 | ||
| 448 | 0 | def handle_event( |
| 449 | "ls_tag_search_blur", | |
| 450 | %{"id" => @tag_search_live_component_id}, | |
| 451 | socket | |
| 452 | ) do | |
| 453 | 0 | socket = |
| 454 | socket | |
| 455 | |> assign( | |
| 456 | tag_search_phrase: nil, | |
| 457 | possible_free_tag_entered: false | |
| 458 | ) | |
| 459 | ||
| 460 | {:noreply, socket} | |
| 461 | end | |
| 462 | ||
| 463 | 0 | def handle_event( |
| 464 | "key_up", | |
| 465 | %{"key" => "Enter"}, | |
| 466 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 467 | socket | |
| 468 | ) do | |
| 469 | 0 | socket = |
| 470 | TagUtilities.handle_free_tagging( | |
| 471 | socket, | |
| 472 | tag_search_phrase, | |
| 473 | String.length(tag_search_phrase), | |
| 474 | @tag_search_live_component_id, | |
| 475 | 0 | socket.assigns.new_tag_colour |
| 476 | ) | |
| 477 | ||
| 478 | {:noreply, socket} | |
| 479 | end | |
| 480 | ||
| 481 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 482 | ||
| 483 | 0 | defp save_timer(socket, :start_timer, timer_params) do |
| 484 | 0 | timer_params = |
| 485 | Map.merge(timer_params, %{ | |
| 486 | "start_stamp" => | |
| 487 | Timer.get_current_timestamp() | |
| 488 | |> Timer.convert_naivedatetime_to_html!(), | |
| 489 | "duration" => "0", | |
| 490 | "duration_time_unit" => "minute", | |
| 491 | "billing_duration" => "0", | |
| 492 | "billing_duration_time_unit" => Units.get_default_billing_increment() | |
| 493 | }) | |
| 494 | ||
| 495 | 0 | case TimeTracking.create_timer(timer_params) do |
| 496 | {:ok, timer} -> | |
| 497 | 0 | timer = TimeTracking.get_formatted_timer_record!(timer.id) |
| 498 | ||
| 499 | 0 | Tag.handle_tag_list_changes( |
| 500 | [], | |
| 501 | 0 | socket.assigns.selected_tag_queue, |
| 502 | 0 | timer.id, |
| 503 | &Categorisation.add_timer_tag(&1, &2), | |
| 504 | &Categorisation.delete_timer_tag(&1, &2) | |
| 505 | ) | |
| 506 | ||
| 507 | 0 | notify_parent({:timer_started, timer}) |
| 508 | ||
| 509 | {:noreply, | |
| 510 | socket | |
| 511 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 512 | ||
| 513 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 514 | {:noreply, assign_form(socket, changeset)} | |
| 515 | end | |
| 516 | end | |
| 517 | ||
| 518 | 0 | defp save_timer(socket, :stop_timer, timer_params) do |
| 519 | 0 | case TimeTracking.update_timer(socket.assigns.timer, timer_params) do |
| 520 | {:ok, timer} -> | |
| 521 | 0 | timer = TimeTracking.get_formatted_timer_record!(timer.id) |
| 522 | 0 | notify_parent({:timer_stopped, timer}) |
| 523 | ||
| 524 | {:noreply, | |
| 525 | socket | |
| 526 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 527 | ||
| 528 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 529 | {:noreply, assign_form(socket, changeset)} | |
| 530 | end | |
| 531 | end | |
| 532 | ||
| 533 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 534 | 0 | assign(socket, :form, to_form(changeset)) |
| 535 | end | |
| 536 | ||
| 537 | @spec assign_project(Phoenix.LiveView.Socket.t()) :: | |
| 538 | Phoenix.LiveView.Socket.t() | |
| 539 | defp assign_project(socket) do | |
| 540 | 0 | projects = Project.populate_projects_list() |
| 541 | ||
| 542 | 0 | assign(socket, projects: projects) |
| 543 | end | |
| 544 | ||
| 545 | @spec assign_business_partner(Phoenix.LiveView.Socket.t()) :: | |
| 546 | Phoenix.LiveView.Socket.t() | |
| 547 | defp assign_business_partner(socket) do | |
| 548 | 0 | business_partners = BusinessPartner.populate_customers_list() |
| 549 | ||
| 550 | 0 | assign(socket, business_partners: business_partners) |
| 551 | end | |
| 552 | ||
| 553 | 0 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 554 | ||
| 555 | defp enable_tag_selector() do | |
| 556 | JS.remove_class("hidden", to: "#timer_ls_tag_search_text_input") | |
| 557 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select") | |
| 558 | |> JS.add_class("hidden", to: "#tag-selector__add-button") | |
| 559 | |> JS.add_class("gap-2", to: "#tag-selector") | |
| 560 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select") | |
| 561 | 0 | |> JS.focus(to: "#timer_ls_tag_search_text_input") |
| 562 | end | |
| 563 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TimerLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | ||
| 5 | alias Klepsidra.TimeTracking | |
| 6 | alias Klepsidra.TimeTracking.Timer | |
| 7 | alias Klepsidra.TimeTracking.TimeUnits, as: Units | |
| 8 | alias Klepsidra.Projects.Project | |
| 9 | alias Klepsidra.BusinessPartners.BusinessPartner | |
| 10 | alias Klepsidra.TimeTracking.ActivityType | |
| 11 | alias Klepsidra.Categorisation | |
| 12 | alias Klepsidra.Categorisation.Tag | |
| 13 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 14 | alias Klepsidra.DynamicCSS | |
| 15 | ||
| 16 | @tag_search_live_component_id "timer_ls_tag_search_live_select_component" | |
| 17 | ||
| 18 | @impl true | |
| 19 | def render(assigns) do | |
| 20 | 2 | ~H""" |
| 21 | <div> | |
| 22 | 2 | <.header> |
| 23 | 2 | <%= @title %> |
| 24 | </.header> | |
| 25 | ||
| 26 | 2 | <.simple_form |
| 27 | 2 | for={@form} |
| 28 | id="timer-form" | |
| 29 | 2 | phx-target={@myself} |
| 30 | phx-change="validate" | |
| 31 | phx-window-keyup="key_up" | |
| 32 | phx-submit="save" | |
| 33 | > | |
| 34 | 2 | <.input field={@form[:start_stamp]} type="datetime-local" label="Start time" /> |
| 35 | 2 | <.input field={@form[:end_stamp]} type="datetime-local" label="End time" /> |
| 36 | ||
| 37 | 2 | <.input field={@form[:duration]} type="text" label="Duration" readonly /> |
| 38 | ||
| 39 | 2 | <.input |
| 40 | 2 | field={@form[:duration_time_unit]} |
| 41 | type="select" | |
| 42 | label="Duration time increment" | |
| 43 | options={Units.construct_duration_unit_options_list(use_primitives?: true)} | |
| 44 | /> | |
| 45 | ||
| 46 | 2 | <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}> |
| 47 | 2 | <div |
| 48 | id="tag-selector__live-select" | |
| 49 | phx-mounted={JS.add_class("hidden", to: "#timer_ls_tag_search_text_input")} | |
| 50 | > | |
| 51 | 2 | <.live_select |
| 52 | 2 | field={@form[:ls_tag_search]} |
| 53 | mode={:tags} | |
| 54 | label="" | |
| 55 | options={[]} | |
| 56 | placeholder="Add tag" | |
| 57 | debounce={80} | |
| 58 | clear_tag_button_class="cursor-pointer px-1 rounded-r-md" | |
| 59 | dropdown_extra_class="bg-white max-h-48 overflow-y-scroll" | |
| 60 | tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold" | |
| 61 | tags_container_class="flex flex-wrap gap-2" | |
| 62 | container_extra_class="rounded border border-none" | |
| 63 | update_min_len={1} | |
| 64 | user_defined_options="true" | |
| 65 | 2 | value={@selected_tags} |
| 66 | phx-blur="ls_tag_search_blur" | |
| 67 | 2 | phx-target={@myself} |
| 68 | > | |
| 69 | 0 | <:option :let={option}> |
| 70 | 0 | <div class="flex" title={option.description}> |
| 71 | 0 | <%= option.label %> |
| 72 | </div> | |
| 73 | </:option> | |
| 74 | 0 | <:tag :let={option}> |
| 75 | 0 | <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}> |
| 76 | 0 | <.link navigate={~p"/tags/#{option.value}"}> |
| 77 | 0 | <%= option.label %> |
| 78 | </.link> | |
| 79 | </div> | |
| 80 | </:tag> | |
| 81 | </.live_select> | |
| 82 | </div> | |
| 83 | ||
| 84 | <div | |
| 85 | id="tag-selector__colour-select" | |
| 86 | class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0" | |
| 87 | > | |
| 88 | 2 | <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} /> |
| 89 | </div> | |
| 90 | ||
| 91 | 2 | <.button |
| 92 | id="tag-selector__add-button" | |
| 93 | class="add-tag-button flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md" | |
| 94 | type="button" | |
| 95 | phx-click={enable_tag_selector()} | |
| 96 | > | |
| 97 | Add tag + | |
| 98 | </.button> | |
| 99 | </div> | |
| 100 | ||
| 101 | 2 | <.input field={@form[:description]} type="textarea" label="Description" /> |
| 102 | ||
| 103 | 2 | <.input field={@form[:project_id]} type="select" label="Project" options={@projects} /> |
| 104 | ||
| 105 | 2 | <.input |
| 106 | 2 | field={@form[:business_partner_id]} |
| 107 | type="select" | |
| 108 | label="Customer" | |
| 109 | 2 | options={@business_partners} |
| 110 | 2 | required={@billable_activity?} |
| 111 | /> | |
| 112 | ||
| 113 | 2 | <.input field={@form[:billable]} type="checkbox" label="Billable?" /> |
| 114 | ||
| 115 | 2 | <div class={unless @billable_activity?, do: "hidden"}> |
| 116 | 2 | <.input field={@form[:billing_duration]} type="text" label="Billable duration" readonly /> |
| 117 | ||
| 118 | 2 | <.input |
| 119 | 2 | field={@form[:billing_duration_time_unit]} |
| 120 | type="select" | |
| 121 | label="Billable time increment" | |
| 122 | options={Units.construct_duration_unit_options_list()} | |
| 123 | /> | |
| 124 | ||
| 125 | 2 | <.input |
| 126 | 2 | field={@form[:activity_type_id]} |
| 127 | type="select" | |
| 128 | label="Activity type" | |
| 129 | 2 | options={@activity_types} |
| 130 | /> | |
| 131 | ||
| 132 | 2 | <.input |
| 133 | 2 | field={@form[:billing_rate]} |
| 134 | type="number" | |
| 135 | label="Hourly billing rate" | |
| 136 | min="0" | |
| 137 | step="0.01" | |
| 138 | /> | |
| 139 | </div> | |
| 140 | ||
| 141 | 2 | <:actions> |
| 142 | 2 | <.button phx-disable-with="Saving...">Save</.button> |
| 143 | </:actions> | |
| 144 | </.simple_form> | |
| 145 | </div> | |
| 146 | """ | |
| 147 | end | |
| 148 | ||
| 149 | @impl true | |
| 150 | @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} | |
| 151 | 2 | def update(%{timer: timer} = assigns, socket) do |
| 152 | 2 | timer_changes = |
| 153 | 2 | case assigns.invocation_context do |
| 154 | :new_timer -> | |
| 155 | 1 | %{ |
| 156 | "duration" => "0", | |
| 157 | "duration_time_unit" => "minute", | |
| 158 | "billing_duration" => "0", | |
| 159 | "billing_duration_time_unit" => Units.get_default_billing_increment() | |
| 160 | } | |
| 161 | ||
| 162 | _ -> | |
| 163 | 1 | %{} |
| 164 | end | |
| 165 | ||
| 166 | 2 | timer = timer |> Klepsidra.Repo.preload(:tags) |
| 167 | ||
| 168 | 2 | changeset = TimeTracking.change_timer(timer, timer_changes) |
| 169 | ||
| 170 | 2 | socket = |
| 171 | socket | |
| 172 | |> assign(assigns) | |
| 173 | |> TagUtilities.generate_tag_options( | |
| 174 | [], | |
| 175 | 2 | Enum.map(timer.tags, fn tag -> tag.id end), |
| 176 | @tag_search_live_component_id | |
| 177 | ) | |
| 178 | |> Phx.Live.Head.push( | |
| 179 | "style[id*=dynamic-style-block]", | |
| 180 | :dynamic, | |
| 181 | "style_declarations", | |
| 182 | 2 | DynamicCSS.generate_tag_styles(timer.tags) |
| 183 | ) | |
| 184 | |> assign( | |
| 185 | 2 | billable_activity?: timer.billable, |
| 186 | new_tag_colour: {"#94a3b8", "#fff"} | |
| 187 | ) | |
| 188 | |> assign_project() | |
| 189 | |> assign_business_partner() | |
| 190 | |> assign_activity_type() | |
| 191 | |> assign_form(changeset) | |
| 192 | ||
| 193 | {:ok, socket} | |
| 194 | end | |
| 195 | ||
| 196 | @impl true | |
| 197 | 0 | def handle_event( |
| 198 | "validate", | |
| 199 | %{"_target" => ["timer", "start_stamp"], "timer" => timer_params}, | |
| 200 | socket | |
| 201 | ) do | |
| 202 | 0 | duration = Timer.assign_timer_duration(timer_params, "duration_time_unit") |
| 203 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 204 | ||
| 205 | 0 | billing_duration = |
| 206 | 0 | if billable do |
| 207 | 0 | Timer.assign_timer_duration(timer_params, "billing_duration_time_unit") |
| 208 | else | |
| 209 | 0 | |
| 210 | end | |
| 211 | ||
| 212 | 0 | changeset = |
| 213 | 0 | socket.assigns.timer |
| 214 | |> TimeTracking.change_timer(%{ | |
| 215 | timer_params | |
| 216 | | "duration" => duration, | |
| 217 | "billing_duration" => billing_duration | |
| 218 | }) | |
| 219 | |> Map.put(:action, :validate) | |
| 220 | ||
| 221 | {:noreply, assign_form(socket, changeset)} | |
| 222 | end | |
| 223 | ||
| 224 | 0 | def handle_event( |
| 225 | "validate", | |
| 226 | %{"_target" => ["timer", "end_stamp"], "timer" => timer_params}, | |
| 227 | socket | |
| 228 | ) do | |
| 229 | 0 | duration = Timer.assign_timer_duration(timer_params, "duration_time_unit") |
| 230 | ||
| 231 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 232 | ||
| 233 | 0 | billing_duration = |
| 234 | 0 | if billable do |
| 235 | 0 | Timer.assign_timer_duration(timer_params, "billing_duration_time_unit") |
| 236 | else | |
| 237 | 0 | |
| 238 | end | |
| 239 | ||
| 240 | 0 | changeset = |
| 241 | 0 | socket.assigns.timer |
| 242 | |> TimeTracking.change_timer(%{ | |
| 243 | timer_params | |
| 244 | | "duration" => duration, | |
| 245 | "billing_duration" => billing_duration | |
| 246 | }) | |
| 247 | |> Map.put(:action, :validate) | |
| 248 | ||
| 249 | {:noreply, assign_form(socket, changeset)} | |
| 250 | end | |
| 251 | ||
| 252 | 0 | def handle_event( |
| 253 | "validate", | |
| 254 | %{"_target" => ["timer", "duration_time_unit"], "timer" => timer_params}, | |
| 255 | socket | |
| 256 | ) do | |
| 257 | 0 | changeset = |
| 258 | 0 | socket.assigns.timer |
| 259 | |> TimeTracking.change_timer(%{ | |
| 260 | timer_params | |
| 261 | | "duration" => Timer.assign_timer_duration(timer_params, "duration_time_unit") | |
| 262 | }) | |
| 263 | |> Map.put(:action, :validate) | |
| 264 | ||
| 265 | {:noreply, assign_form(socket, changeset)} | |
| 266 | end | |
| 267 | ||
| 268 | 0 | def handle_event( |
| 269 | "validate", | |
| 270 | %{"_target" => ["timer", "billing_duration_time_unit"], "timer" => timer_params}, | |
| 271 | socket | |
| 272 | ) do | |
| 273 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 274 | ||
| 275 | 0 | billing_duration = |
| 276 | 0 | if billable do |
| 277 | 0 | Timer.assign_timer_duration(timer_params, "billing_duration_time_unit") |
| 278 | else | |
| 279 | 0 | |
| 280 | end | |
| 281 | ||
| 282 | 0 | changeset = |
| 283 | 0 | socket.assigns.timer |
| 284 | |> TimeTracking.change_timer(%{ | |
| 285 | timer_params | |
| 286 | | "billing_duration" => billing_duration | |
| 287 | }) | |
| 288 | |> Map.put(:action, :validate) | |
| 289 | ||
| 290 | {:noreply, assign_form(socket, changeset)} | |
| 291 | end | |
| 292 | ||
| 293 | 0 | def handle_event( |
| 294 | "validate", | |
| 295 | %{"_target" => ["timer", "billable"], "timer" => timer_params}, | |
| 296 | socket | |
| 297 | ) do | |
| 298 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 299 | ||
| 300 | 0 | billing_duration = |
| 301 | 0 | if billable do |
| 302 | 0 | Timer.assign_timer_duration(timer_params, "billing_duration_time_unit") |
| 303 | else | |
| 304 | 0 | |
| 305 | end | |
| 306 | ||
| 307 | 0 | activity_type_id = |
| 308 | case billable do | |
| 309 | 0 | true -> socket.assigns.timer.activity_type_id |
| 310 | 0 | false -> "" |
| 311 | end | |
| 312 | ||
| 313 | 0 | changeset = |
| 314 | 0 | socket.assigns.timer |
| 315 | |> TimeTracking.change_timer(%{ | |
| 316 | timer_params | |
| 317 | | "activity_type_id" => activity_type_id, | |
| 318 | "billing_duration" => billing_duration | |
| 319 | }) | |
| 320 | |> Map.put(:action, :validate) | |
| 321 | ||
| 322 | 0 | socket = |
| 323 | socket | |
| 324 | |> assign(billable_activity?: billable) | |
| 325 | |> assign_activity_type() | |
| 326 | ||
| 327 | {:noreply, assign_form(socket, changeset)} | |
| 328 | end | |
| 329 | ||
| 330 | 0 | def handle_event( |
| 331 | "validate", | |
| 332 | %{"_target" => ["timer", "activity_type_id"], "timer" => timer_params}, | |
| 333 | socket | |
| 334 | ) do | |
| 335 | 0 | billable = Timer.read_checkbox(timer_params["billable"]) |
| 336 | ||
| 337 | 0 | billing_rate = |
| 338 | 0 | if billable do |
| 339 | 0 | activity_type_id = timer_params["activity_type_id"] |
| 340 | ||
| 341 | 0 | Klepsidra.TimeTracking.get_activity_type!(activity_type_id).billing_rate |
| 342 | else | |
| 343 | 0 | |
| 344 | end | |
| 345 | ||
| 346 | 0 | changeset = |
| 347 | 0 | socket.assigns.timer |
| 348 | |> TimeTracking.change_timer(%{ | |
| 349 | timer_params | |
| 350 | | "billing_rate" => billing_rate | |
| 351 | }) | |
| 352 | |> Map.put(:action, :validate) | |
| 353 | ||
| 354 | {:noreply, assign_form(socket, changeset)} | |
| 355 | end | |
| 356 | ||
| 357 | 0 | def handle_event( |
| 358 | "validate", | |
| 359 | %{"_target" => ["timer", "ls_tag_search"], "timer" => %{"ls_tag_search" => tags_applied}}, | |
| 360 | socket | |
| 361 | ) do | |
| 362 | 0 | Tag.handle_tag_list_changes( |
| 363 | 0 | socket.assigns.selected_tag_queue, |
| 364 | tags_applied, | |
| 365 | 0 | socket.assigns.timer.id, |
| 366 | &Categorisation.add_timer_tag(&1, &2), | |
| 367 | &Categorisation.delete_timer_tag(&1, &2) | |
| 368 | ) | |
| 369 | ||
| 370 | 0 | socket = |
| 371 | TagUtilities.generate_tag_options( | |
| 372 | socket, | |
| 373 | 0 | socket.assigns.selected_tag_queue, |
| 374 | tags_applied, | |
| 375 | @tag_search_live_component_id, | |
| 376 | 0 | parent_tag_select_id: socket.assigns.parent_tag_select_id |
| 377 | ) | |
| 378 | |> Phx.Live.Head.push( | |
| 379 | "style[id*=dynamic-style-block]", | |
| 380 | :dynamic, | |
| 381 | "style_declarations", | |
| 382 | DynamicCSS.generate_tag_styles(tags_applied) | |
| 383 | ) | |
| 384 | ||
| 385 | 0 | socket = |
| 386 | socket | |
| 387 | |> assign( | |
| 388 | tag_search_phrase: nil, | |
| 389 | possible_free_tag_entered: false | |
| 390 | ) | |
| 391 | ||
| 392 | {:noreply, socket} | |
| 393 | end | |
| 394 | ||
| 395 | @doc """ | |
| 396 | Validate event which fires only once the last of the tags has been cleared | |
| 397 | from a `live_select` component. | |
| 398 | """ | |
| 399 | 0 | def handle_event( |
| 400 | "validate", | |
| 401 | %{ | |
| 402 | "_target" => ["timer", "ls_tag_search_empty_selection"], | |
| 403 | "timer" => %{"ls_tag_search_empty_selection" => ""} | |
| 404 | }, | |
| 405 | socket | |
| 406 | ) do | |
| 407 | 0 | Tag.handle_tag_list_changes( |
| 408 | 0 | socket.assigns.selected_tag_queue, |
| 409 | [], | |
| 410 | 0 | socket.assigns.timer.id, |
| 411 | &Categorisation.add_timer_tag(&1, &2), | |
| 412 | &Categorisation.delete_timer_tag(&1, &2) | |
| 413 | ) | |
| 414 | ||
| 415 | 0 | socket.assigns.parent_tag_select_id && |
| 416 | 0 | send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: []) |
| 417 | ||
| 418 | 0 | socket = |
| 419 | socket | |
| 420 | |> assign( | |
| 421 | tag_search_phrase: nil, | |
| 422 | possible_free_tag_entered: false | |
| 423 | ) | |
| 424 | ||
| 425 | {:noreply, socket} | |
| 426 | end | |
| 427 | ||
| 428 | 0 | def handle_event( |
| 429 | "validate", | |
| 430 | %{ | |
| 431 | "_target" => ["timer", "bg_colour"], | |
| 432 | "timer" => %{ | |
| 433 | "bg_colour" => bg_colour | |
| 434 | } | |
| 435 | }, | |
| 436 | socket | |
| 437 | ) do | |
| 438 | 0 | fg_colour = |
| 439 | case ColorContrast.calc_contrast(bg_colour) do | |
| 440 | 0 | {:ok, fg_colour} -> fg_colour |
| 441 | 0 | {:error, _} -> "#fff" |
| 442 | end | |
| 443 | ||
| 444 | 0 | socket = |
| 445 | socket | |
| 446 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 447 | ||
| 448 | {:noreply, socket} | |
| 449 | end | |
| 450 | ||
| 451 | 0 | def handle_event("validate", %{"timer" => timer_params}, socket) do |
| 452 | 0 | changeset = |
| 453 | 0 | socket.assigns.timer |
| 454 | |> TimeTracking.change_timer(timer_params) | |
| 455 | |> Map.put(:action, :validate) | |
| 456 | ||
| 457 | {:noreply, assign_form(socket, changeset)} | |
| 458 | end | |
| 459 | ||
| 460 | def handle_event("save", %{"timer" => timer_params}, socket) do | |
| 461 | 1 | save_timer(socket, socket.assigns.action, timer_params) |
| 462 | end | |
| 463 | ||
| 464 | 0 | def handle_event( |
| 465 | "live_select_change", | |
| 466 | %{ | |
| 467 | "field" => "timer_ls_tag_search", | |
| 468 | "id" => live_select_id, | |
| 469 | "text" => tag_search_phrase | |
| 470 | }, | |
| 471 | socket | |
| 472 | ) do | |
| 473 | 0 | tag_search_results = |
| 474 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 475 | |> TagUtilities.tag_options_for_live_select() | |
| 476 | ||
| 477 | 0 | send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results) |
| 478 | ||
| 479 | 0 | socket = |
| 480 | socket | |
| 481 | |> assign( | |
| 482 | tag_search_phrase: tag_search_phrase, | |
| 483 | possible_free_tag_entered: true | |
| 484 | ) | |
| 485 | ||
| 486 | {:noreply, socket} | |
| 487 | end | |
| 488 | ||
| 489 | 0 | def handle_event( |
| 490 | "ls_tag_search_blur", | |
| 491 | %{"id" => @tag_search_live_component_id}, | |
| 492 | socket | |
| 493 | ) do | |
| 494 | 0 | socket = |
| 495 | socket | |
| 496 | |> assign( | |
| 497 | tag_search_phrase: nil, | |
| 498 | possible_free_tag_entered: false | |
| 499 | ) | |
| 500 | ||
| 501 | {:noreply, socket} | |
| 502 | end | |
| 503 | ||
| 504 | 0 | def handle_event( |
| 505 | "key_up", | |
| 506 | %{"key" => "Enter"}, | |
| 507 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 508 | socket | |
| 509 | ) do | |
| 510 | 0 | socket = |
| 511 | TagUtilities.handle_free_tagging( | |
| 512 | socket, | |
| 513 | tag_search_phrase, | |
| 514 | String.length(tag_search_phrase), | |
| 515 | @tag_search_live_component_id, | |
| 516 | 0 | socket.assigns.new_tag_colour |
| 517 | ) | |
| 518 | ||
| 519 | {:noreply, socket} | |
| 520 | end | |
| 521 | ||
| 522 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 523 | ||
| 524 | 1 | defp save_timer(socket, :edit_timer, timer_params) do |
| 525 | 1 | case TimeTracking.update_timer(socket.assigns.timer, timer_params) do |
| 526 | {:ok, timer} -> | |
| 527 | 1 | if timer.start_stamp != "" && timer.end_stamp != "" && not is_nil(timer.end_stamp) do |
| 528 | 1 | notify_parent({:updated_closed_timer, timer}) |
| 529 | else | |
| 530 | 0 | notify_parent({:updated_open_timer, timer}) |
| 531 | end | |
| 532 | ||
| 533 | {:noreply, | |
| 534 | socket | |
| 535 | 1 | |> push_patch(to: socket.assigns.patch)} |
| 536 | ||
| 537 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 538 | {:noreply, assign_form(socket, changeset)} | |
| 539 | end | |
| 540 | end | |
| 541 | ||
| 542 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do | |
| 543 | 2 | assign(socket, :form, to_form(changeset)) |
| 544 | end | |
| 545 | ||
| 546 | @spec assign_project(Phoenix.LiveView.Socket.t()) :: | |
| 547 | Phoenix.LiveView.Socket.t() | |
| 548 | defp assign_project(socket) do | |
| 549 | 2 | projects = Project.populate_projects_list() |
| 550 | ||
| 551 | 2 | assign(socket, projects: projects) |
| 552 | end | |
| 553 | ||
| 554 | @spec assign_business_partner(Phoenix.LiveView.Socket.t()) :: | |
| 555 | Phoenix.LiveView.Socket.t() | |
| 556 | defp assign_business_partner(socket) do | |
| 557 | 2 | business_partners = BusinessPartner.populate_customers_list() |
| 558 | ||
| 559 | 2 | assign(socket, business_partners: business_partners) |
| 560 | end | |
| 561 | ||
| 562 | @spec assign_activity_type(Phoenix.LiveView.Socket.t()) :: | |
| 563 | Phoenix.LiveView.Socket.t() | |
| 564 | defp assign_activity_type(socket) do | |
| 565 | 2 | activity_types = |
| 566 | 2 | case socket.assigns.billable_activity? do |
| 567 | true -> | |
| 568 | 0 | ActivityType.populate_activity_types_list() |
| 569 | ||
| 570 | 2 | _ -> |
| 571 | [{"", ""}] | |
| 572 | end | |
| 573 | ||
| 574 | 2 | assign(socket, activity_types: activity_types) |
| 575 | end | |
| 576 | ||
| 577 | 1 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 578 | ||
| 579 | defp enable_tag_selector() do | |
| 580 | JS.remove_class("hidden", to: "#timer_ls_tag_search_text_input") | |
| 581 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select") | |
| 582 | |> JS.add_class("hidden", to: "#tag-selector__add-button") | |
| 583 | |> JS.add_class("gap-2", to: "#tag-selector") | |
| 584 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select") | |
| 585 | 2 | |> JS.focus(to: "#timer_ls_tag_search_text_input") |
| 586 | end | |
| 587 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TimerLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.TimeTracking | |
| 6 | import LiveToast | |
| 7 | alias Klepsidra.TimeTracking.Timer | |
| 8 | alias Klepsidra.TimeTracking.TimeUnits, as: Units | |
| 9 | alias KlepsidraWeb.Live.NoteLive.NoteFormComponent | |
| 10 | ||
| 11 | @impl true | |
| 12 | 8 | def mount(_params, _session, socket) do |
| 13 | 8 | socket = |
| 14 | socket | |
| 15 | |> assign(display_help: false) | |
| 16 | |> stream(:timers, TimeTracking.list_timers_with_customers()) | |
| 17 | ||
| 18 | {:ok, socket} | |
| 19 | end | |
| 20 | ||
| 21 | @impl true | |
| 22 | 11 | def handle_params(params, _url, socket) do |
| 23 | 11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 24 | end | |
| 25 | ||
| 26 | defp apply_action(socket, :new_timer, _params) do | |
| 27 | socket | |
| 28 | |> assign(:page_title, "Manual Timer") | |
| 29 | 1 | |> assign(:timer, %Timer{}) |
| 30 | end | |
| 31 | ||
| 32 | defp apply_action(socket, :edit_timer, %{"id" => id}) do | |
| 33 | socket | |
| 34 | |> assign(:page_title, "Edit Timer") | |
| 35 | 1 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 36 | end | |
| 37 | ||
| 38 | defp apply_action(socket, :start_timer, _params) do | |
| 39 | 0 | billing_duration_unit = Units.get_default_billing_increment() |
| 40 | ||
| 41 | socket | |
| 42 | |> assign(:page_title, "Starting Timer") | |
| 43 | |> assign( | |
| 44 | duration_unit: "minute", | |
| 45 | billing_duration_unit: billing_duration_unit | |
| 46 | ) | |
| 47 | 0 | |> assign(:timer, %Timer{}) |
| 48 | end | |
| 49 | ||
| 50 | defp apply_action(socket, :stop_timer, %{"id" => id}) do | |
| 51 | 0 | start_timestamp = TimeTracking.get_timer!(id).start_stamp |
| 52 | 0 | clocked_out = Timer.clock_out(start_timestamp, :minute) |
| 53 | 0 | billing_duration_unit = Units.get_default_billing_increment() |
| 54 | ||
| 55 | 0 | billing_duration = |
| 56 | Timer.calculate_timer_duration( | |
| 57 | start_timestamp, | |
| 58 | 0 | clocked_out.end_timestamp, |
| 59 | String.to_atom(billing_duration_unit) | |
| 60 | ) | |
| 61 | ||
| 62 | socket | |
| 63 | |> assign(:page_title, "Clock out") | |
| 64 | |> assign( | |
| 65 | clocked_out: clocked_out, | |
| 66 | duration_unit: "minute", | |
| 67 | billing_duration: billing_duration, | |
| 68 | billing_duration_unit: billing_duration_unit | |
| 69 | ) | |
| 70 | 0 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 71 | end | |
| 72 | ||
| 73 | defp apply_action(socket, :index, _params) do | |
| 74 | socket | |
| 75 | |> assign(:page_title, "Activity Timers") | |
| 76 | 9 | |> assign(:timer, nil) |
| 77 | end | |
| 78 | ||
| 79 | defp apply_action(socket, :new_note, %{"id" => id} = _params) do | |
| 80 | socket | |
| 81 | |> assign(:page_title, "New note") | |
| 82 | 0 | |> assign(:timer_id, id) |
| 83 | end | |
| 84 | ||
| 85 | @impl true | |
| 86 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_open_timer, timer}}, socket) do |
| 87 | {:noreply, handle_open_timer(socket, timer)} | |
| 88 | end | |
| 89 | ||
| 90 | @impl true | |
| 91 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do |
| 92 | {:noreply, handle_closed_timer(socket, timer)} | |
| 93 | end | |
| 94 | ||
| 95 | @impl true | |
| 96 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, timer}}, socket) do |
| 97 | {:noreply, handle_updated_timer(socket, timer)} | |
| 98 | end | |
| 99 | ||
| 100 | @impl true | |
| 101 | 1 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, timer}}, socket) do |
| 102 | {:noreply, handle_updated_timer(socket, timer)} | |
| 103 | end | |
| 104 | ||
| 105 | @impl true | |
| 106 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_started, timer}}, socket) do |
| 107 | {:noreply, handle_started_timer(socket, timer)} | |
| 108 | end | |
| 109 | ||
| 110 | @impl true | |
| 111 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do |
| 112 | {:noreply, handle_closed_timer(socket, timer)} | |
| 113 | end | |
| 114 | ||
| 115 | @impl true | |
| 116 | 0 | def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do |
| 117 | {:noreply, handle_saved_note(socket, note)} | |
| 118 | end | |
| 119 | ||
| 120 | @impl true | |
| 121 | 1 | def handle_event("delete", %{"id" => id}, socket) do |
| 122 | 1 | timer = TimeTracking.get_timer!(id) |
| 123 | 1 | {:ok, _} = TimeTracking.delete_timer(timer) |
| 124 | ||
| 125 | {:noreply, handle_deleted_timer(socket, timer, :timers)} | |
| 126 | end | |
| 127 | ||
| 128 | @impl true | |
| 129 | # def handle_event("keyboard_event", %{"key" => "s"} = _params, socket) do | |
| 130 | # {:noreply, | |
| 131 | # assign(socket, | |
| 132 | # live_action: :start, | |
| 133 | # page_title: "Starting Timer", | |
| 134 | # start_timestamp: | |
| 135 | # Timer.get_current_timestamp() | |
| 136 | # |> Timer.convert_naivedatetime_to_html!(), | |
| 137 | # timer: %Timer{} | |
| 138 | # )} | |
| 139 | # end | |
| 140 | ||
| 141 | # def handle_event("keyboard_event", %{"key" => "?"} = _params, socket) do | |
| 142 | # {:noreply, | |
| 143 | # assign(socket, | |
| 144 | # display_help: true | |
| 145 | # )} | |
| 146 | # end | |
| 147 | ||
| 148 | 0 | def handle_event("keyboard_event", _params, socket) do |
| 149 | {:noreply, socket} | |
| 150 | end | |
| 151 | ||
| 152 | defp handle_started_timer(socket, timer) do | |
| 153 | socket | |
| 154 | |> stream_insert(:timers, timer, at: 0) | |
| 155 | 0 | |> put_toast(:info, "Timer started") |
| 156 | end | |
| 157 | ||
| 158 | defp handle_open_timer(socket, timer) do | |
| 159 | socket | |
| 160 | |> stream_insert(:timers, timer) | |
| 161 | 0 | |> put_toast(:info, "Timer created successfully") |
| 162 | end | |
| 163 | ||
| 164 | defp handle_closed_timer(socket, timer) do | |
| 165 | socket | |
| 166 | |> stream_insert(:timers, timer) | |
| 167 | 0 | |> put_toast(:info, "Timer stopped") |
| 168 | end | |
| 169 | ||
| 170 | defp handle_updated_timer(socket, timer) do | |
| 171 | socket | |
| 172 | |> stream_insert(:timers, timer) | |
| 173 | 1 | |> put_toast(:info, "Timer updated successfully") |
| 174 | end | |
| 175 | ||
| 176 | defp handle_deleted_timer(socket, timer, source_stream) do | |
| 177 | socket | |
| 178 | |> stream_delete(source_stream, timer) | |
| 179 | 1 | |> put_toast(:info, "Timer deleted successfully") |
| 180 | end | |
| 181 | ||
| 182 | defp handle_saved_note(socket, _note) do | |
| 183 | socket | |
| 184 | 0 | |> put_toast(:info, "Note created successfully") |
| 185 | end | |
| 186 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.TimerLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | import LiveToast | |
| 6 | ||
| 7 | alias Klepsidra.TimeTracking | |
| 8 | alias Klepsidra.TimeTracking.Timer | |
| 9 | alias Klepsidra.TimeTracking.TimeUnits, as: Units | |
| 10 | alias KlepsidraWeb.Live.NoteLive.NoteFormComponent | |
| 11 | alias Klepsidra.Categorisation | |
| 12 | alias Klepsidra.Categorisation.Tag | |
| 13 | alias KlepsidraWeb.TagLive.TagUtilities | |
| 14 | alias LiveSelect.Component | |
| 15 | alias Klepsidra.DynamicCSS | |
| 16 | ||
| 17 | defmodule TagSearch do | |
| 18 | @moduledoc """ | |
| 19 | The `TagSearch` module defines an embedded `tag_search` schema | |
| 20 | containing the tags for this timer. | |
| 21 | """ | |
| 22 | use Ecto.Schema | |
| 23 | ||
| 24 | import Ecto.Changeset | |
| 25 | ||
| 26 | @type t :: %__MODULE__{ | |
| 27 | tag_search: Tag.t() | |
| 28 | } | |
| 29 | 6 | embedded_schema do |
| 30 | embeds_many(:tag_search, Tag, on_replace: :delete) | |
| 31 | field(:bg_colour, :string) | |
| 32 | end | |
| 33 | ||
| 34 | @doc false | |
| 35 | def changeset(schema \\ %__MODULE__{}, params) do | |
| 36 | cast(schema, params, []) | |
| 37 | 2 | |> cast_embed(:tag_search) |
| 38 | end | |
| 39 | end | |
| 40 | ||
| 41 | @tag_search_live_component_id "tag_form_tag_search_live_select_component" | |
| 42 | ||
| 43 | @impl true | |
| 44 | 2 | def mount(params, _session, socket) do |
| 45 | 2 | timer_id = Map.get(params, "id") |
| 46 | 2 | timer = Klepsidra.TimeTracking.get_timer!(timer_id) |> Klepsidra.Repo.preload(:tags) |
| 47 | 2 | return_to = Map.get(params, "return_to", "/timers") |
| 48 | ||
| 49 | 2 | notes = TimeTracking.get_note_by_timer_id!(timer_id) |
| 50 | ||
| 51 | 2 | note_metadata = title_notes_section(length(notes)) |
| 52 | ||
| 53 | 2 | socket = |
| 54 | socket | |
| 55 | |> TagUtilities.generate_tag_options( | |
| 56 | [], | |
| 57 | 2 | Enum.map(timer.tags, fn tag -> tag.id end), |
| 58 | @tag_search_live_component_id | |
| 59 | ) | |
| 60 | |> Phx.Live.Head.push( | |
| 61 | "style[id*=dynamic-style-block]", | |
| 62 | :dynamic, | |
| 63 | "style_declarations", | |
| 64 | 2 | DynamicCSS.generate_tag_styles(timer.tags) |
| 65 | ) | |
| 66 | ||
| 67 | 2 | socket = |
| 68 | socket | |
| 69 | |> stream(:notes, notes) | |
| 70 | |> assign( | |
| 71 | live_select_form: to_form(TagSearch.changeset(%{}), as: "tag_form"), | |
| 72 | new_tag_colour: {"#94a3b8", "#fff"}, | |
| 73 | 2 | note_count: note_metadata.note_count, |
| 74 | 2 | notes_title: note_metadata.section_title, |
| 75 | timer_id: timer_id, | |
| 76 | return_to: return_to | |
| 77 | ) | |
| 78 | ||
| 79 | {:ok, socket} | |
| 80 | end | |
| 81 | ||
| 82 | @impl true | |
| 83 | 2 | def handle_params(params, _url, socket) do |
| 84 | 2 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 85 | end | |
| 86 | ||
| 87 | defp apply_action(socket, :show, %{"id" => id}) do | |
| 88 | socket | |
| 89 | 2 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 90 | |> assign(:timer, TimeTracking.get_timer!(id)) | |
| 91 | 2 | |> assign(:note, %Klepsidra.TimeTracking.Note{}) |
| 92 | end | |
| 93 | ||
| 94 | defp apply_action(socket, :edit_timer, %{"id" => id}) do | |
| 95 | socket | |
| 96 | |> assign(:page_title, "Edit Timer") | |
| 97 | 0 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 98 | end | |
| 99 | ||
| 100 | defp apply_action(socket, :stop_timer, %{"id" => id}) do | |
| 101 | 0 | start_timestamp = TimeTracking.get_timer!(id).start_stamp |
| 102 | 0 | clocked_out = Timer.clock_out(start_timestamp, :minute) |
| 103 | 0 | billing_duration_unit = Units.get_default_billing_increment() |
| 104 | ||
| 105 | 0 | billing_duration = |
| 106 | Timer.calculate_timer_duration( | |
| 107 | start_timestamp, | |
| 108 | 0 | clocked_out.end_timestamp, |
| 109 | String.to_existing_atom(billing_duration_unit) | |
| 110 | ) | |
| 111 | ||
| 112 | socket | |
| 113 | |> assign(:page_title, "Clock out") | |
| 114 | |> assign( | |
| 115 | clocked_out: clocked_out, | |
| 116 | duration_unit: "minute", | |
| 117 | billing_duration: billing_duration, | |
| 118 | billing_duration_unit: billing_duration_unit | |
| 119 | ) | |
| 120 | 0 | |> assign(:timer, TimeTracking.get_timer!(id)) |
| 121 | end | |
| 122 | ||
| 123 | defp apply_action(socket, :new_note, %{"id" => id} = _params) do | |
| 124 | socket | |
| 125 | |> assign(:page_title, "New note") | |
| 126 | 0 | |> assign(:timer_id, id) |
| 127 | end | |
| 128 | ||
| 129 | 0 | defp apply_action(socket, nil, _params), do: socket |
| 130 | ||
| 131 | defp apply_action(socket, :edit_note, %{"id" => _id, "note_id" => note_id}) do | |
| 132 | socket | |
| 133 | 0 | |> assign( |
| 134 | note: TimeTracking.get_note!(note_id), | |
| 135 | 0 | page_title: page_title(socket.assigns.live_action) |
| 136 | ) | |
| 137 | end | |
| 138 | ||
| 139 | 2 | defp page_title(:show), do: "Show Timer" |
| 140 | 0 | defp page_title(:edit_timer), do: "Edit Timer" |
| 141 | 0 | defp page_title(:new_note), do: "New note" |
| 142 | 0 | defp page_title(:edit_note), do: "Edit note" |
| 143 | ||
| 144 | @impl true | |
| 145 | 0 | def handle_event( |
| 146 | "live_select_change", | |
| 147 | %{ | |
| 148 | "field" => "tag_form_tag_search", | |
| 149 | "id" => @tag_search_live_component_id, | |
| 150 | "text" => tag_search_phrase | |
| 151 | }, | |
| 152 | socket | |
| 153 | ) do | |
| 154 | 0 | tag_search_results = |
| 155 | Categorisation.search_tags_by_name_content(tag_search_phrase) | |
| 156 | |> TagUtilities.tag_options_for_live_select() | |
| 157 | ||
| 158 | 0 | send_update(Component, |
| 159 | id: @tag_search_live_component_id, | |
| 160 | options: tag_search_results | |
| 161 | ) | |
| 162 | ||
| 163 | 0 | socket = |
| 164 | socket | |
| 165 | |> assign( | |
| 166 | tag_search_phrase: tag_search_phrase, | |
| 167 | possible_free_tag_entered: true | |
| 168 | ) | |
| 169 | ||
| 170 | {:noreply, socket} | |
| 171 | end | |
| 172 | ||
| 173 | 0 | def handle_event( |
| 174 | "change", | |
| 175 | %{ | |
| 176 | "_target" => ["tag_form", "tag_search_empty_selection"], | |
| 177 | "tag_form" => %{ | |
| 178 | "tag_search_empty_selection" => "", | |
| 179 | "tag_search_text_input" => _tag_search_phrase | |
| 180 | } | |
| 181 | }, | |
| 182 | socket | |
| 183 | ) do | |
| 184 | 0 | Tag.handle_tag_list_changes( |
| 185 | 0 | socket.assigns.selected_tag_queue, |
| 186 | [], | |
| 187 | 0 | socket.assigns.timer.id, |
| 188 | &Categorisation.add_timer_tag(&1, &2), | |
| 189 | &Categorisation.delete_timer_tag(&1, &2) | |
| 190 | ) | |
| 191 | ||
| 192 | 0 | socket = |
| 193 | socket | |
| 194 | |> assign( | |
| 195 | tag_search_phrase: nil, | |
| 196 | possible_free_tag_entered: false | |
| 197 | ) | |
| 198 | ||
| 199 | {:noreply, socket} | |
| 200 | end | |
| 201 | ||
| 202 | 0 | def handle_event( |
| 203 | "change", | |
| 204 | %{ | |
| 205 | "_target" => ["tag_form", "tag_search"], | |
| 206 | "tag_form" => %{ | |
| 207 | "tag_search" => selected_tags, | |
| 208 | "tag_search_text_input" => _tag_search_phrase | |
| 209 | } | |
| 210 | }, | |
| 211 | socket | |
| 212 | ) do | |
| 213 | 0 | Tag.handle_tag_list_changes( |
| 214 | 0 | socket.assigns.selected_tag_queue, |
| 215 | selected_tags, | |
| 216 | 0 | socket.assigns.timer.id, |
| 217 | &Categorisation.add_timer_tag(&1, &2), | |
| 218 | &Categorisation.delete_timer_tag(&1, &2) | |
| 219 | ) | |
| 220 | ||
| 221 | 0 | socket = |
| 222 | TagUtilities.generate_tag_options( | |
| 223 | socket, | |
| 224 | 0 | socket.assigns.selected_tag_queue, |
| 225 | selected_tags, | |
| 226 | @tag_search_live_component_id | |
| 227 | ) | |
| 228 | |> Phx.Live.Head.push( | |
| 229 | "style[id*=dynamic-style-block]", | |
| 230 | :dynamic, | |
| 231 | "style_declarations", | |
| 232 | DynamicCSS.generate_tag_styles(selected_tags) | |
| 233 | ) | |
| 234 | ||
| 235 | 0 | socket = |
| 236 | socket | |
| 237 | |> assign( | |
| 238 | tag_search_phrase: nil, | |
| 239 | possible_free_tag_entered: false | |
| 240 | ) | |
| 241 | ||
| 242 | {:noreply, socket} | |
| 243 | end | |
| 244 | ||
| 245 | 0 | def handle_event( |
| 246 | "change", | |
| 247 | %{ | |
| 248 | "_target" => ["tag_form", "bg_colour"], | |
| 249 | "tag_form" => %{ | |
| 250 | "bg_colour" => bg_colour, | |
| 251 | "tag_search_text_input" => _tag_search_phrase | |
| 252 | } | |
| 253 | }, | |
| 254 | socket | |
| 255 | ) do | |
| 256 | 0 | fg_colour = |
| 257 | case ColorContrast.calc_contrast(bg_colour) do | |
| 258 | 0 | {:ok, fg_colour} -> fg_colour |
| 259 | 0 | {:error, _} -> "#fff" |
| 260 | end | |
| 261 | ||
| 262 | 0 | socket = |
| 263 | socket | |
| 264 | |> assign(new_tag_colour: {bg_colour, fg_colour}) | |
| 265 | ||
| 266 | {:noreply, socket} | |
| 267 | end | |
| 268 | ||
| 269 | 0 | def handle_event( |
| 270 | "ls_tag_search_blur", | |
| 271 | %{"id" => @tag_search_live_component_id}, | |
| 272 | socket | |
| 273 | ) do | |
| 274 | 0 | socket = |
| 275 | socket | |
| 276 | |> assign( | |
| 277 | tag_search_phrase: nil, | |
| 278 | possible_free_tag_entered: false | |
| 279 | ) | |
| 280 | ||
| 281 | {:noreply, socket} | |
| 282 | end | |
| 283 | ||
| 284 | 0 | def handle_event( |
| 285 | "key_up", | |
| 286 | %{"key" => "Enter"}, | |
| 287 | %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} = | |
| 288 | socket | |
| 289 | ) do | |
| 290 | 0 | socket = |
| 291 | TagUtilities.handle_free_tagging( | |
| 292 | socket, | |
| 293 | tag_search_phrase, | |
| 294 | String.length(tag_search_phrase), | |
| 295 | @tag_search_live_component_id, | |
| 296 | 0 | socket.assigns.new_tag_colour |
| 297 | ) | |
| 298 | ||
| 299 | {:noreply, socket} | |
| 300 | end | |
| 301 | ||
| 302 | 0 | def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket} |
| 303 | ||
| 304 | 0 | def handle_event("delete_note", %{"id" => id}, socket) do |
| 305 | {:noreply, handle_deleted_note(socket, TimeTracking.get_note!(id))} | |
| 306 | end | |
| 307 | ||
| 308 | @impl true | |
| 309 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved, _timer}}, socket) do |
| 310 | {:noreply, socket} | |
| 311 | end | |
| 312 | ||
| 313 | @impl true | |
| 314 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do |
| 315 | {:noreply, handle_closed_timer(socket, timer)} | |
| 316 | end | |
| 317 | ||
| 318 | @impl true | |
| 319 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, _timer}}, socket) do |
| 320 | {:noreply, socket} | |
| 321 | end | |
| 322 | ||
| 323 | @impl true | |
| 324 | 0 | def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, _timer}}, socket) do |
| 325 | {:noreply, socket} | |
| 326 | end | |
| 327 | ||
| 328 | @impl true | |
| 329 | 0 | def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do |
| 330 | {:noreply, handle_closed_timer(socket, timer)} | |
| 331 | end | |
| 332 | ||
| 333 | @impl true | |
| 334 | 0 | def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:updated_note, note}}, socket) do |
| 335 | {:noreply, handle_updated_note(socket, note)} | |
| 336 | end | |
| 337 | ||
| 338 | @impl true | |
| 339 | 0 | def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do |
| 340 | {:noreply, handle_saved_note(socket, note)} | |
| 341 | end | |
| 342 | ||
| 343 | defp handle_closed_timer(socket, _timer) do | |
| 344 | # closed_timer_duration = {timer.duration, timer.duration_time_unit} | |
| 345 | ||
| 346 | socket | |
| 347 | 0 | |> put_toast(:info, "Timer stopped") |
| 348 | end | |
| 349 | ||
| 350 | defp handle_saved_note(socket, note) do | |
| 351 | 0 | note_metadata = title_notes_section(socket.assigns.note_count + 1) |
| 352 | ||
| 353 | socket | |
| 354 | 0 | |> assign(:note_count, note_metadata.note_count) |
| 355 | 0 | |> assign(:notes_title, note_metadata.section_title) |
| 356 | |> stream_insert(:notes, note, at: 0) | |
| 357 | 0 | |> put_toast(:info, "Note created successfully") |
| 358 | end | |
| 359 | ||
| 360 | defp handle_updated_note(socket, note) do | |
| 361 | 0 | note_metadata = title_notes_section(socket.assigns.note_count + 1) |
| 362 | ||
| 363 | socket | |
| 364 | 0 | |> assign(:note_count, note_metadata.note_count) |
| 365 | 0 | |> assign(:notes_title, note_metadata.section_title) |
| 366 | |> stream_insert(:notes, note) | |
| 367 | 0 | |> put_toast(:info, "Note updated successfully") |
| 368 | end | |
| 369 | ||
| 370 | defp handle_deleted_note(socket, note) do | |
| 371 | 0 | {:ok, _} = TimeTracking.delete_note(note) |
| 372 | ||
| 373 | 0 | note_metadata = title_notes_section(socket.assigns.note_count - 1) |
| 374 | ||
| 375 | socket | |
| 376 | 0 | |> assign(:note_count, note_metadata.note_count) |
| 377 | 0 | |> assign(:notes_title, note_metadata.section_title) |
| 378 | |> stream_delete(:notes, note) | |
| 379 | 0 | |> put_toast(:info, "Note deleted successfully") |
| 380 | end | |
| 381 | ||
| 382 | defp title_notes_section(note_count) when is_integer(note_count) do | |
| 383 | 2 | title_note_count = if note_count > 0, do: note_count, else: "" |
| 384 | 2 | note_pluralisation = if note_count == 1, do: "Note", else: "Notes" |
| 385 | ||
| 386 | 2 | %{ |
| 387 | note_count: note_count, | |
| 388 | title_pluralisation: note_pluralisation, | |
| 389 | section_title: [title_note_count, note_pluralisation] |> Enum.join(" ") | |
| 390 | } | |
| 391 | end | |
| 392 | ||
| 393 | defp enable_tag_selector() do | |
| 394 | JS.remove_class("hidden", to: "#tag_form_tag_search_text_input") | |
| 395 | |> JS.remove_class("hidden", to: "#tag-selector__colour-select--show") | |
| 396 | |> JS.add_class("hidden", to: "#tag-selector__add-button--show") | |
| 397 | |> JS.add_class("gap-2", to: "#tag-selector--show") | |
| 398 | |> JS.add_class("flex-auto", to: "#tag-selector__live-select--show") | |
| 399 | 2 | |> JS.focus(to: "#tag_form_tag_search_text_input") |
| 400 | end | |
| 401 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.UserLive.FormComponent do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_component | |
| 4 | ||
| 5 | alias Klepsidra.Accounts | |
| 6 | ||
| 7 | @impl true | |
| 8 | def render(assigns) do | |
| 9 | 9 | ~H""" |
| 10 | <div> | |
| 11 | 3 | <.header> |
| 12 | 3 | <%= @title %> |
| 13 | 3 | <:subtitle>Use this form to manage user records in your database.</:subtitle> |
| 14 | </.header> | |
| 15 | ||
| 16 | 7 | <.simple_form |
| 17 | 7 | for={@form} |
| 18 | id="user-form" | |
| 19 | 7 | phx-target={@myself} |
| 20 | phx-change="validate" | |
| 21 | phx-submit="save" | |
| 22 | > | |
| 23 | 7 | <.input field={@form[:user_name]} type="text" label="User name" /> |
| 24 | 7 | <.input field={@form[:login_email]} type="text" label="Login email" /> |
| 25 | 7 | <.input field={@form[:password_hash]} type="text" label="Password hash" /> |
| 26 | 7 | <:actions> |
| 27 | 7 | <.button phx-disable-with="Saving...">Save User</.button> |
| 28 | </:actions> | |
| 29 | </.simple_form> | |
| 30 | </div> | |
| 31 | """ | |
| 32 | end | |
| 33 | ||
| 34 | @impl true | |
| 35 | 3 | def update(%{user: user} = assigns, socket) do |
| 36 | {:ok, | |
| 37 | socket | |
| 38 | |> assign(assigns) | |
| 39 | |> assign_new(:form, fn -> | |
| 40 | 3 | to_form(Accounts.change_user(user)) |
| 41 | end)} | |
| 42 | end | |
| 43 | ||
| 44 | @impl true | |
| 45 | 3 | def handle_event("validate", %{"user" => user_params}, socket) do |
| 46 | 3 | changeset = Accounts.change_user(socket.assigns.user, user_params) |
| 47 | {:noreply, assign(socket, form: to_form(changeset, action: :validate))} | |
| 48 | end | |
| 49 | ||
| 50 | def handle_event("save", %{"user" => user_params}, socket) do | |
| 51 | 3 | save_user(socket, socket.assigns.action, user_params) |
| 52 | end | |
| 53 | ||
| 54 | 2 | defp save_user(socket, :edit, user_params) do |
| 55 | 2 | case Accounts.update_user(socket.assigns.user, user_params) do |
| 56 | {:ok, user} -> | |
| 57 | 2 | notify_parent({:saved, user}) |
| 58 | ||
| 59 | {:noreply, | |
| 60 | socket | |
| 61 | |> put_flash(:info, "User updated successfully") | |
| 62 | 2 | |> push_patch(to: socket.assigns.patch)} |
| 63 | ||
| 64 | 0 | {:error, %Ecto.Changeset{} = changeset} -> |
| 65 | {:noreply, assign(socket, form: to_form(changeset))} | |
| 66 | end | |
| 67 | end | |
| 68 | ||
| 69 | 0 | defp save_user(socket, :new, user_params) do |
| 70 | 1 | case Accounts.create_user(user_params) do |
| 71 | {:ok, user} -> | |
| 72 | 0 | notify_parent({:saved, user}) |
| 73 | ||
| 74 | {:noreply, | |
| 75 | socket | |
| 76 | |> put_flash(:info, "User created successfully") | |
| 77 | 0 | |> push_patch(to: socket.assigns.patch)} |
| 78 | ||
| 79 | 1 | {:error, %Ecto.Changeset{} = changeset} -> |
| 80 | {:noreply, assign(socket, form: to_form(changeset))} | |
| 81 | end | |
| 82 | end | |
| 83 | ||
| 84 | 2 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) |
| 85 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.UserLive.Index do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Accounts | |
| 6 | alias Klepsidra.Accounts.User | |
| 7 | ||
| 8 | @impl true | |
| 9 | 8 | def mount(_params, _session, socket) do |
| 10 | {:ok, stream(socket, :users, Accounts.list_users())} | |
| 11 | end | |
| 12 | ||
| 13 | @impl true | |
| 14 | 11 | def handle_params(params, _url, socket) do |
| 15 | 11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
| 16 | end | |
| 17 | ||
| 18 | defp apply_action(socket, :edit, %{"id" => id}) do | |
| 19 | socket | |
| 20 | |> assign(:page_title, "Edit User") | |
| 21 | 1 | |> assign(:user, Accounts.get_user!(id)) |
| 22 | end | |
| 23 | ||
| 24 | defp apply_action(socket, :new, _params) do | |
| 25 | socket | |
| 26 | |> assign(:page_title, "New User") | |
| 27 | 1 | |> assign(:user, %User{}) |
| 28 | end | |
| 29 | ||
| 30 | defp apply_action(socket, :index, _params) do | |
| 31 | socket | |
| 32 | |> assign(:page_title, "Listing Users") | |
| 33 | 9 | |> assign(:user, nil) |
| 34 | end | |
| 35 | ||
| 36 | @impl true | |
| 37 | 1 | def handle_info({KlepsidraWeb.UserLive.FormComponent, {:saved, user}}, socket) do |
| 38 | {:noreply, stream_insert(socket, :users, user)} | |
| 39 | end | |
| 40 | ||
| 41 | @impl true | |
| 42 | 1 | def handle_event("delete", %{"id" => id}, socket) do |
| 43 | 1 | user = Accounts.get_user!(id) |
| 44 | 1 | {:ok, _} = Accounts.delete_user(user) |
| 45 | ||
| 46 | {:noreply, stream_delete(socket, :users, user)} | |
| 47 | end | |
| 48 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.UserLive.Show do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | use KlepsidraWeb, :live_view | |
| 4 | ||
| 5 | alias Klepsidra.Accounts | |
| 6 | ||
| 7 | @impl true | |
| 8 | 4 | def mount(_params, _session, socket) do |
| 9 | {:ok, socket} | |
| 10 | end | |
| 11 | ||
| 12 | @impl true | |
| 13 | 6 | def handle_params(%{"id" => id}, _, socket) do |
| 14 | {:noreply, | |
| 15 | socket | |
| 16 | 6 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 17 | |> assign(:user, Accounts.get_user!(id))} | |
| 18 | end | |
| 19 | ||
| 20 | 5 | defp page_title(:show), do: "Show User" |
| 21 | 1 | defp page_title(:edit), do: "Edit User" |
| 22 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.Router do | |
| 1 | use KlepsidraWeb, :router | |
| 2 | ||
| 3 | @moduledoc false | |
| 4 | ||
| 5 | 39 | pipeline :browser do |
| 6 | plug :accepts, ["html"] | |
| 7 | plug :fetch_session | |
| 8 | plug :fetch_live_flash | |
| 9 | ||
| 10 | plug :put_root_layout, | |
| 11 | html: {KlepsidraWeb.Layouts, :root} | |
| 12 | ||
| 13 | plug :protect_from_forgery | |
| 14 | ||
| 15 | plug :put_content_security_policy, | |
| 16 | default_src: "'none'", | |
| 17 | script_src: "'self' 'nonce'", | |
| 18 | style_src: "'self' 'nonce'", | |
| 19 | connect_src: "'self'", | |
| 20 | img_src: "'self' data:", | |
| 21 | font_src: "'self'", | |
| 22 | frame_src: "'self' 'nonce'" | |
| 23 | ||
| 24 | plug :put_secure_browser_headers | |
| 25 | end | |
| 26 | ||
| 27 | 0 | pipeline :api do |
| 28 | plug :accepts, ["json"] | |
| 29 | end | |
| 30 | ||
| 31 | scope "/", KlepsidraWeb do | |
| 32 | pipe_through :browser | |
| 33 | ||
| 34 | 0 | live "/", StartPageLive |
| 35 | 0 | live "/start_timer", StartPageLive, :start_timer |
| 36 | 0 | live "/stop_timer/:id", StartPageLive, :stop_timer |
| 37 | 0 | live "/new_timer", StartPageLive, :new_timer |
| 38 | 0 | live "/timer_notes/:id/notes/new", StartPageLive, :new_note |
| 39 | 0 | live "/edit_timer/:id", StartPageLive, :edit_timer |
| 40 | ||
| 41 | 9 | live "/users", UserLive.Index, :index |
| 42 | 1 | live "/users/new", UserLive.Index, :new |
| 43 | 1 | live "/users/:id/edit", UserLive.Index, :edit |
| 44 | ||
| 45 | 5 | live "/users/:id", UserLive.Show, :show |
| 46 | 1 | live "/users/:id/show/edit", UserLive.Show, :edit |
| 47 | ||
| 48 | 9 | live "/tags", TagLive.Index, :index |
| 49 | 1 | live "/tags/new", TagLive.Index, :new |
| 50 | 1 | live "/tags/:id/edit", TagLive.Index, :edit |
| 51 | ||
| 52 | 5 | live "/tags/:id", TagLive.Show, :show |
| 53 | 1 | live "/tags/:id/show/edit", TagLive.Show, :edit |
| 54 | ||
| 55 | 9 | live "/timers", TimerLive.Index, :index |
| 56 | 1 | live "/timers/new", TimerLive.Index, :new_timer |
| 57 | 0 | live "/timers/start", TimerLive.Index, :start_timer |
| 58 | 1 | live "/timers/:id/edit", TimerLive.Index, :edit_timer |
| 59 | 0 | live "/timers/:id/stop", TimerLive.Index, :stop_timer |
| 60 | 0 | live "/timers/:id/notes/new", TimerLive.Index, :new_note |
| 61 | ||
| 62 | 2 | live "/timers/:id", TimerLive.Show, :show |
| 63 | 0 | live "/timers/:id/stop-timer", TimerLive.Show, :stop_timer |
| 64 | 0 | live "/timers/:id/new-note", TimerLive.Show, :new_note |
| 65 | 0 | live "/timers/:id/notes/:note_id/edit", TimerLive.Show, :edit_note |
| 66 | 0 | live "/timers/:id/show/edit", TimerLive.Show, :edit_timer |
| 67 | ||
| 68 | 9 | live "/activity_types", ActivityTypeLive.Index, :index |
| 69 | 1 | live "/activity_types/new", ActivityTypeLive.Index, :new |
| 70 | 1 | live "/activity_types/:id/edit", ActivityTypeLive.Index, :edit |
| 71 | ||
| 72 | 5 | live "/activity_types/:id", ActivityTypeLive.Show, :show |
| 73 | 1 | live "/activity_types/:id/show/edit", ActivityTypeLive.Show, :edit |
| 74 | ||
| 75 | 0 | live "/notes", NoteLive.Index, :index |
| 76 | 0 | live "/notes/new", NoteLive.Index, :new |
| 77 | 0 | live "/notes/:id/edit", NoteLive.Index, :edit |
| 78 | ||
| 79 | 0 | live "/notes/:id", NoteLive.Show, :show |
| 80 | 0 | live "/notes/:id/show/edit", NoteLive.Show, :edit |
| 81 | ||
| 82 | 9 | live "/projects", ProjectLive.Index, :index |
| 83 | 1 | live "/projects/new", ProjectLive.Index, :new |
| 84 | 1 | live "/projects/:id/edit", ProjectLive.Index, :edit |
| 85 | ||
| 86 | 5 | live "/projects/:id", ProjectLive.Show, :show |
| 87 | 1 | live "/projects/:id/show/edit", ProjectLive.Show, :edit |
| 88 | ||
| 89 | 9 | live "/customers", BusinessPartnerLive.Index, :index |
| 90 | 1 | live "/customers/new", BusinessPartnerLive.Index, :new |
| 91 | 1 | live "/customers/:id/edit", BusinessPartnerLive.Index, :edit |
| 92 | ||
| 93 | 5 | live "/customers/:id", BusinessPartnerLive.Show, :show |
| 94 | 1 | live "/customers/:id/show/edit", BusinessPartnerLive.Show, :edit |
| 95 | ||
| 96 | 4 | live "/journal_entry_types", JournalEntryTypesLive.Index, :index |
| 97 | 1 | live "/journal_entry_types/new", JournalEntryTypesLive.Index, :new |
| 98 | 0 | live "/journal_entry_types/:id/edit", JournalEntryTypesLive.Index, :edit |
| 99 | ||
| 100 | 4 | live "/journal_entry_types/:id", JournalEntryTypesLive.Show, :show |
| 101 | 1 | live "/journal_entry_types/:id/show/edit", JournalEntryTypesLive.Show, :edit |
| 102 | ||
| 103 | 0 | live "/journal_entries", JournalEntryLive.Index, :index |
| 104 | 0 | live "/journal_entries/new", JournalEntryLive.Index, :new |
| 105 | 0 | live "/journal_entries/:id/edit", JournalEntryLive.Index, :edit |
| 106 | ||
| 107 | 0 | live "/journal_entries/:id", JournalEntryLive.Show, :show |
| 108 | 0 | live "/journal_entries/:id/show/edit", JournalEntryLive.Show, :edit |
| 109 | ||
| 110 | 0 | live "/reporting/activities_timed", TimerLive.ActivityTimeReporting, :index |
| 111 | ||
| 112 | 0 | live "/reporting/activities_timed/timers/:id/edit", |
| 113 | TimerLive.ActivityTimeReporting, | |
| 114 | :edit_timer | |
| 115 | end | |
| 116 | ||
| 117 | # Other scopes may use custom stacks. | |
| 118 | # scope "/api", KlepsidraWeb do | |
| 119 | # pipe_through :api | |
| 120 | # end | |
| 121 | ||
| 122 | # Enable LiveDashboard and Swoosh mailbox preview in development | |
| 123 | if Application.compile_env(:klepsidra, :dev_routes) do | |
| 124 | # If you want to use the LiveDashboard in production, you should put | |
| 125 | # it behind authentication and allow only admins to access it. | |
| 126 | # If your application does not have an admins-only section yet, | |
| 127 | # you can use Plug.BasicAuth to set up some basic authentication | |
| 128 | # as long as you are also using SSL (which you should anyway). | |
| 129 | import Phoenix.LiveDashboard.Router | |
| 130 | ||
| 131 | scope "/dev" do | |
| 132 | pipe_through :browser | |
| 133 | ||
| 134 | live_dashboard "/dashboard", metrics: KlepsidraWeb.Telemetry | |
| 135 | forward "/mailbox", Plug.Swoosh.MailboxPreview | |
| 136 | end | |
| 137 | end | |
| 138 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.Telemetry do | |
| 1 | use Supervisor | |
| 2 | import Telemetry.Metrics | |
| 3 | ||
| 4 | @moduledoc false | |
| 5 | ||
| 6 | def start_link(arg) do | |
| 7 | 1 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) |
| 8 | end | |
| 9 | ||
| 10 | @impl true | |
| 11 | def init(_arg) do | |
| 12 | 1 | children = [ |
| 13 | # Telemetry poller will execute the given period measurements | |
| 14 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics | |
| 15 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} | |
| 16 | # Add reporters as children of your supervision tree. | |
| 17 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} | |
| 18 | ] | |
| 19 | ||
| 20 | 1 | Supervisor.init(children, strategy: :one_for_one) |
| 21 | end | |
| 22 | ||
| 23 | 0 | def metrics do |
| 24 | [ | |
| 25 | # Phoenix Metrics | |
| 26 | summary("phoenix.endpoint.start.system_time", | |
| 27 | unit: {:native, :millisecond} | |
| 28 | ), | |
| 29 | summary("phoenix.endpoint.stop.duration", | |
| 30 | unit: {:native, :millisecond} | |
| 31 | ), | |
| 32 | summary("phoenix.router_dispatch.start.system_time", | |
| 33 | tags: [:route], | |
| 34 | unit: {:native, :millisecond} | |
| 35 | ), | |
| 36 | summary("phoenix.router_dispatch.exception.duration", | |
| 37 | tags: [:route], | |
| 38 | unit: {:native, :millisecond} | |
| 39 | ), | |
| 40 | summary("phoenix.router_dispatch.stop.duration", | |
| 41 | tags: [:route], | |
| 42 | unit: {:native, :millisecond} | |
| 43 | ), | |
| 44 | summary("phoenix.socket_connected.duration", | |
| 45 | unit: {:native, :millisecond} | |
| 46 | ), | |
| 47 | summary("phoenix.channel_join.duration", | |
| 48 | unit: {:native, :millisecond} | |
| 49 | ), | |
| 50 | summary("phoenix.channel_handled_in.duration", | |
| 51 | tags: [:event], | |
| 52 | unit: {:native, :millisecond} | |
| 53 | ), | |
| 54 | ||
| 55 | # Database Metrics | |
| 56 | summary("klepsidra.repo.query.total_time", | |
| 57 | unit: {:native, :millisecond}, | |
| 58 | description: "The sum of the other measurements" | |
| 59 | ), | |
| 60 | summary("klepsidra.repo.query.decode_time", | |
| 61 | unit: {:native, :millisecond}, | |
| 62 | description: "The time spent decoding the data received from the database" | |
| 63 | ), | |
| 64 | summary("klepsidra.repo.query.query_time", | |
| 65 | unit: {:native, :millisecond}, | |
| 66 | description: "The time spent executing the query" | |
| 67 | ), | |
| 68 | summary("klepsidra.repo.query.queue_time", | |
| 69 | unit: {:native, :millisecond}, | |
| 70 | description: "The time spent waiting for a database connection" | |
| 71 | ), | |
| 72 | summary("klepsidra.repo.query.idle_time", | |
| 73 | unit: {:native, :millisecond}, | |
| 74 | description: | |
| 75 | "The time the connection spent waiting before being checked out for the query" | |
| 76 | ), | |
| 77 | ||
| 78 | # VM Metrics | |
| 79 | summary("vm.memory.total", unit: {:byte, :kilobyte}), | |
| 80 | summary("vm.total_run_queue_lengths.total"), | |
| 81 | summary("vm.total_run_queue_lengths.cpu"), | |
| 82 | summary("vm.total_run_queue_lengths.io") | |
| 83 | ] | |
| 84 | end | |
| 85 | ||
| 86 | 1 | defp periodic_measurements do |
| 87 | [ | |
| 88 | # A module, function and arguments to be invoked periodically. | |
| 89 | # This function must call :telemetry.execute/3 and a metric must be added above. | |
| 90 | # {KlepsidraWeb, :count_users, []} | |
| 91 | ] | |
| 92 | end | |
| 93 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule KlepsidraWeb.ConnCase do | |
| 1 | @moduledoc """ | |
| 2 | This module defines the test case to be used by | |
| 3 | tests that require setting up a connection. | |
| 4 | ||
| 5 | Such tests rely on `Phoenix.ConnTest` and also | |
| 6 | import other functionality to make it easier | |
| 7 | to build common data structures and query the data layer. | |
| 8 | ||
| 9 | Finally, if the test case interacts with the database, | |
| 10 | we enable the SQL sandbox, so changes done to the database | |
| 11 | are reverted at the end of every test. If you are using | |
| 12 | PostgreSQL, you can even run database tests asynchronously | |
| 13 | by setting `use KlepsidraWeb.ConnCase, async: true`, although | |
| 14 | this option is not recommended for other databases. | |
| 15 | """ | |
| 16 | ||
| 17 | use ExUnit.CaseTemplate | |
| 18 | ||
| 19 | 10 | using do |
| 20 | quote do | |
| 21 | # The default endpoint for testing | |
| 22 | @endpoint KlepsidraWeb.Endpoint | |
| 23 | ||
| 24 | use KlepsidraWeb, :verified_routes | |
| 25 | ||
| 26 | # Import conveniences for testing with connections | |
| 27 | import Plug.Conn | |
| 28 | import Phoenix.ConnTest | |
| 29 | import KlepsidraWeb.ConnCase | |
| 30 | end | |
| 31 | end | |
| 32 | ||
| 33 | setup tags do | |
| 34 | 43 | Klepsidra.DataCase.setup_sandbox(tags) |
| 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} | |
| 36 | end | |
| 37 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.DataCase do | |
| 1 | @moduledoc """ | |
| 2 | This module defines the setup for tests requiring | |
| 3 | access to the application's data layer. | |
| 4 | ||
| 5 | You may define functions here to be used as helpers in | |
| 6 | your tests. | |
| 7 | ||
| 8 | Finally, if the test case interacts with the database, | |
| 9 | we enable the SQL sandbox, so changes done to the database | |
| 10 | are reverted at the end of every test. If you are using | |
| 11 | PostgreSQL, you can even run database tests asynchronously | |
| 12 | by setting `use Klepsidra.DataCase, async: true`, although | |
| 13 | this option is not recommended for other databases. | |
| 14 | """ | |
| 15 | ||
| 16 | use ExUnit.CaseTemplate | |
| 17 | ||
| 18 | 8 | using do |
| 19 | quote do | |
| 20 | alias Klepsidra.Repo | |
| 21 | ||
| 22 | import Ecto | |
| 23 | import Ecto.Changeset | |
| 24 | import Ecto.Query | |
| 25 | import Klepsidra.DataCase | |
| 26 | end | |
| 27 | end | |
| 28 | ||
| 29 | setup tags do | |
| 30 | 126 | Klepsidra.DataCase.setup_sandbox(tags) |
| 31 | :ok | |
| 32 | end | |
| 33 | ||
| 34 | @doc """ | |
| 35 | Sets up the sandbox based on the test tags. | |
| 36 | """ | |
| 37 | def setup_sandbox(tags) do | |
| 38 | 169 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Klepsidra.Repo, shared: not tags[:async]) |
| 39 | 169 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) |
| 40 | end | |
| 41 | ||
| 42 | @doc """ | |
| 43 | A helper that transforms changeset errors into a map of messages. | |
| 44 | ||
| 45 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) | |
| 46 | assert "password is too short" in errors_on(changeset).password | |
| 47 | assert %{password: ["password is too short"]} = errors_on(changeset) | |
| 48 | ||
| 49 | """ | |
| 50 | def errors_on(changeset) do | |
| 51 | 0 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> |
| 52 | 0 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> |
| 53 | 0 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() |
| 54 | end) | |
| 55 | end) | |
| 56 | end | |
| 57 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.AccountsFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Accounts` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a user. | |
| 8 | """ | |
| 9 | def user_fixture(attrs \\ %{}) do | |
| 10 | 12 | {:ok, user} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | login_email: "some login_email", | |
| 14 | password_hash: "some password_hash", | |
| 15 | user_name: "some user_name" | |
| 16 | }) | |
| 17 | |> Klepsidra.Accounts.create_user() | |
| 18 | ||
| 19 | 12 | user |
| 20 | end | |
| 21 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.BusinessPartnersFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.BusinessPartners` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a business_partner. | |
| 8 | """ | |
| 9 | def business_partner_fixture(attrs \\ %{}) do | |
| 10 | 12 | {:ok, business_partner} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | active: true, | |
| 14 | customer: true, | |
| 15 | description: "some description", | |
| 16 | name: "some name", | |
| 17 | supplier: true | |
| 18 | }) | |
| 19 | |> Klepsidra.BusinessPartners.create_business_partner() | |
| 20 | ||
| 21 | 12 | business_partner |
| 22 | end | |
| 23 | ||
| 24 | @doc """ | |
| 25 | Generate a note. | |
| 26 | """ | |
| 27 | def note_fixture(attrs \\ %{}) do | |
| 28 | 0 | {:ok, note} = |
| 29 | attrs | |
| 30 | |> Enum.into(%{ | |
| 31 | business_partner_id: 42, | |
| 32 | note: "some note", | |
| 33 | user_id: 42 | |
| 34 | }) | |
| 35 | |> Klepsidra.BusinessPartners.create_note() | |
| 36 | ||
| 37 | 0 | note |
| 38 | end | |
| 39 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.CategorisationFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Categorisation` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a tag. | |
| 8 | """ | |
| 9 | def tag_fixture(attrs \\ %{}) do | |
| 10 | 12 | {:ok, tag} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | colour: "some colour", | |
| 14 | description: "some description", | |
| 15 | name: "some tag" | |
| 16 | }) | |
| 17 | |> Klepsidra.Categorisation.create_tag() | |
| 18 | ||
| 19 | 12 | tag |
| 20 | end | |
| 21 | ||
| 22 | @doc """ | |
| 23 | Generate a project_tag. | |
| 24 | """ | |
| 25 | def project_tag_fixture(attrs \\ %{}) do | |
| 26 | 0 | {:ok, project_tag} = |
| 27 | attrs | |
| 28 | |> Enum.into(%{ | |
| 29 | project_id: 42, | |
| 30 | tag_id: 42 | |
| 31 | }) | |
| 32 | |> Klepsidra.Categorisation.create_project_tag() | |
| 33 | ||
| 34 | 0 | project_tag |
| 35 | end | |
| 36 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.JournalsFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Journals` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a journal_entry. | |
| 8 | """ | |
| 9 | def journal_entry_fixture(attrs \\ %{}) do | |
| 10 | 0 | {:ok, journal_entry} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | journal_for: "2024-01-02", | |
| 14 | entry_text_html: "some entry_text_html", | |
| 15 | entry_text_markdown: "some entry_text_markdown", | |
| 16 | is_private: true, | |
| 17 | is_short_entry: true, | |
| 18 | mood: "some mood" | |
| 19 | }) | |
| 20 | |> Klepsidra.Journals.create_journal_entry() | |
| 21 | ||
| 22 | 0 | journal_entry |
| 23 | end | |
| 24 | ||
| 25 | @doc """ | |
| 26 | Generate a journal_entry_types. | |
| 27 | """ | |
| 28 | def journal_entry_types_fixture(attrs \\ %{}) do | |
| 29 | 10 | {:ok, journal_entry_types} = |
| 30 | attrs | |
| 31 | |> Enum.into(%{ | |
| 32 | description: "some description", | |
| 33 | name: "some name" | |
| 34 | }) | |
| 35 | |> Klepsidra.Journals.create_journal_entry_types() | |
| 36 | ||
| 37 | 10 | journal_entry_types |
| 38 | end | |
| 39 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.LocalisationFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Localisation` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a language. | |
| 8 | """ | |
| 9 | def language_fixture(attrs \\ %{}) do | |
| 10 | 6 | {:ok, language} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | "iso_639-1_language_code": "some iso_639-1", | |
| 14 | "iso_639-2_language_code": "some iso_639-2", | |
| 15 | "iso_639-3_language_code": "some iso_639-3", | |
| 16 | language_name: "some language_name" | |
| 17 | }) | |
| 18 | |> Klepsidra.Localisation.create_language() | |
| 19 | ||
| 20 | 6 | language |
| 21 | end | |
| 22 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.LocationsFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Locations` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a feature_class. | |
| 8 | """ | |
| 9 | def feature_class_fixture(attrs \\ %{}) do | |
| 10 | 6 | {:ok, feature_class} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | description: "some description", | |
| 14 | feature_class: "P" | |
| 15 | }) | |
| 16 | |> Klepsidra.Locations.create_feature_class() | |
| 17 | ||
| 18 | 6 | feature_class |
| 19 | end | |
| 20 | ||
| 21 | @doc """ | |
| 22 | Generate a feature_code. | |
| 23 | """ | |
| 24 | def feature_code_fixture(attrs \\ %{}) do | |
| 25 | 0 | {:ok, feature_code} = |
| 26 | attrs | |
| 27 | |> Enum.into(%{ | |
| 28 | feature_code: "PPL", | |
| 29 | feature_class: "P", | |
| 30 | order: 42, | |
| 31 | description: "some description", | |
| 32 | note: "some note" | |
| 33 | }) | |
| 34 | |> Klepsidra.Locations.create_feature_code() | |
| 35 | ||
| 36 | 0 | feature_code |
| 37 | end | |
| 38 | ||
| 39 | @doc """ | |
| 40 | Generate a continent. | |
| 41 | """ | |
| 42 | def continent_fixture(attrs \\ %{}) do | |
| 43 | 6 | {:ok, continent} = |
| 44 | attrs | |
| 45 | |> Enum.into(%{ | |
| 46 | continent_code: "some continent_code", | |
| 47 | continent_name: "some continent_name", | |
| 48 | geoname_id: 42 | |
| 49 | }) | |
| 50 | |> Klepsidra.Locations.create_continent() | |
| 51 | ||
| 52 | 6 | continent |
| 53 | end | |
| 54 | ||
| 55 | @doc """ | |
| 56 | Generate a country. | |
| 57 | """ | |
| 58 | def country_fixture(attrs \\ %{}) do | |
| 59 | 0 | {:ok, country} = |
| 60 | attrs | |
| 61 | |> Enum.into(%{ | |
| 62 | iso_country_code: "some iso", | |
| 63 | iso_3_country_code: "some iso_3", | |
| 64 | iso_numeric_country_code: 42, | |
| 65 | country_name: "some country_name", | |
| 66 | capital: "some capital", | |
| 67 | population: 42, | |
| 68 | area: 42, | |
| 69 | continent_code: "some continent", | |
| 70 | currency_code: "some currency_code", | |
| 71 | currency_name: "some currency_name", | |
| 72 | equivalent_fips_code: "some equivalent_fips_code", | |
| 73 | fips: "some fips", | |
| 74 | languages: "some languages", | |
| 75 | neighbours: "some neighbours", | |
| 76 | phone: "some phone", | |
| 77 | postal_code_format: "some postal_code_format", | |
| 78 | postal_code_regex: "some postal_code_regex", | |
| 79 | tld: "some tld", | |
| 80 | geoname_id: 42 | |
| 81 | }) | |
| 82 | |> Klepsidra.Locations.create_country() | |
| 83 | ||
| 84 | 0 | country |
| 85 | end | |
| 86 | ||
| 87 | @doc """ | |
| 88 | Generate a administrative_division1. | |
| 89 | """ | |
| 90 | def administrative_division_1_fixture(attrs \\ %{}) do | |
| 91 | 0 | {:ok, administrative_division_1} = |
| 92 | attrs | |
| 93 | |> Enum.into(%{ | |
| 94 | administrative_division_1_code: "ENG", | |
| 95 | country_code: "GB", | |
| 96 | administrative_division_1_name: "some administrative_division_name", | |
| 97 | administrative_division_1_name_ascii: "some administrative_division_name_ascii", | |
| 98 | geoname_id: 42 | |
| 99 | }) | |
| 100 | |> Klepsidra.Locations.create_administrative_division_1() | |
| 101 | ||
| 102 | 0 | administrative_division_1 |
| 103 | end | |
| 104 | ||
| 105 | @doc """ | |
| 106 | Generate a administrative_division2. | |
| 107 | """ | |
| 108 | def administrative_division_2_fixture(attrs \\ %{}) do | |
| 109 | 0 | {:ok, administrative_division_2} = |
| 110 | attrs | |
| 111 | |> Enum.into(%{ | |
| 112 | administrative_division_2_code: "some administrative_division2_code", | |
| 113 | administrative_division_1_code: "some administrative_division1_code", | |
| 114 | country_code: "GB", | |
| 115 | administrative_division_2_ascii_name: "some administrative_division_ascii_name", | |
| 116 | administrative_division_2_name: "some administrative_division_name", | |
| 117 | geoname_id: 42 | |
| 118 | }) | |
| 119 | |> Klepsidra.Locations.create_administrative_division_2() | |
| 120 | ||
| 121 | 0 | administrative_division_2 |
| 122 | end | |
| 123 | ||
| 124 | @doc """ | |
| 125 | Generate a city. | |
| 126 | """ | |
| 127 | def city_fixture(attrs \\ %{}) do | |
| 128 | 0 | {:ok, city} = |
| 129 | attrs | |
| 130 | |> Enum.into(%{ | |
| 131 | geoname_id: 42, | |
| 132 | name: "some name", | |
| 133 | alternatenames: "some alternatenames", | |
| 134 | ascii_name: "some asciiname", | |
| 135 | country_code: "some country_code", | |
| 136 | cc2: "some cc2", | |
| 137 | feature_class: "P", | |
| 138 | feature_code: "PPL", | |
| 139 | latitude: 120.5, | |
| 140 | longitude: 120.5, | |
| 141 | administrative_division_1_code: "some admin1_code", | |
| 142 | administrative_division_2_code: "some admin2_code", | |
| 143 | administrative_division_3_code: "some admin3_code", | |
| 144 | administrative_division_4_code: "some admin4_code", | |
| 145 | population: 42, | |
| 146 | elevation: 42, | |
| 147 | dem: 42, | |
| 148 | timezone: "some timezone", | |
| 149 | modification_date: ~D[2024-10-08] | |
| 150 | }) | |
| 151 | |> Klepsidra.Locations.create_city() | |
| 152 | ||
| 153 | 0 | city |
| 154 | end | |
| 155 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.ProjectFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Project` context. | |
| 4 | """ | |
| 5 | ||
| 6 | # @doc """ | |
| 7 | # Generate a note. | |
| 8 | # """ | |
| 9 | # def note_fixture(attrs \\ %{}) do | |
| 10 | # {:ok, note} = | |
| 11 | # attrs | |
| 12 | # |> Enum.into(%{ | |
| 13 | # note: "some note", | |
| 14 | # project_id: 42, | |
| 15 | # user_id: 42 | |
| 16 | # }) | |
| 17 | # |> Klepsidra.Project.create_note() | |
| 18 | ||
| 19 | # note | |
| 20 | # end | |
| 21 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.ProjectsFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.Projects` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a project. | |
| 8 | """ | |
| 9 | def project_fixture(attrs \\ %{}) do | |
| 10 | 12 | {:ok, project} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | active: true, | |
| 14 | description: "some description", | |
| 15 | name: "some name" | |
| 16 | }) | |
| 17 | |> Klepsidra.Projects.create_project() | |
| 18 | ||
| 19 | 12 | project |
| 20 | end | |
| 21 | ||
| 22 | @doc """ | |
| 23 | Generate a note. | |
| 24 | """ | |
| 25 | def note_fixture(attrs \\ %{}) do | |
| 26 | 0 | {:ok, note} = |
| 27 | attrs | |
| 28 | |> Enum.into(%{ | |
| 29 | note: "some note", | |
| 30 | project_id: 42, | |
| 31 | user_id: 42 | |
| 32 | }) | |
| 33 | |> Klepsidra.Projects.create_note() | |
| 34 | ||
| 35 | 0 | note |
| 36 | end | |
| 37 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Klepsidra.TimeTrackingFixtures do | |
| 1 | @moduledoc """ | |
| 2 | This module defines test helpers for creating | |
| 3 | entities via the `Klepsidra.TimeTracking` context. | |
| 4 | """ | |
| 5 | ||
| 6 | @doc """ | |
| 7 | Generate a timer. | |
| 8 | """ | |
| 9 | def timer_fixture(attrs \\ %{}) do | |
| 10 | 11 | {:ok, timer} = |
| 11 | attrs | |
| 12 | |> Enum.into(%{ | |
| 13 | description: "some description", | |
| 14 | duration: 42, | |
| 15 | duration_time_unit: "minute", | |
| 16 | end_stamp: "2024-12-09 12:34:56", | |
| 17 | billing_duration: 42, | |
| 18 | billing_duration_time_unit: "minute", | |
| 19 | start_stamp: "2024-12-09 12:30", | |
| 20 | billing_rate: "0" | |
| 21 | }) | |
| 22 | |> Klepsidra.TimeTracking.create_timer() | |
| 23 | ||
| 24 | 11 | timer |
| 25 | end | |
| 26 | ||
| 27 | @doc """ | |
| 28 | Generate a note. | |
| 29 | """ | |
| 30 | def note_fixture(attrs \\ %{}) do | |
| 31 | 0 | {:ok, note} = |
| 32 | attrs | |
| 33 | |> Enum.into(%{ | |
| 34 | note: "some note" | |
| 35 | }) | |
| 36 | |> Klepsidra.TimeTracking.create_note() | |
| 37 | ||
| 38 | 0 | note |
| 39 | end | |
| 40 | ||
| 41 | @doc """ | |
| 42 | Generate a activity_type. | |
| 43 | """ | |
| 44 | def activity_type_fixture(attrs \\ %{}) do | |
| 45 | 12 | {:ok, activity_type} = |
| 46 | attrs | |
| 47 | |> Enum.into(%{ | |
| 48 | active: true, | |
| 49 | name: "some activity_type", | |
| 50 | billing_rate: "120.5" | |
| 51 | }) | |
| 52 | |> Klepsidra.TimeTracking.create_activity_type() | |
| 53 | ||
| 54 | 12 | activity_type |
| 55 | end | |
| 56 | end |